diff --git a/src/main/java/bwapi/BotWrapperAsync.java b/src/main/java/bwapi/BotWrapperAsync.java index 17e361d..9083eb4 100644 --- a/src/main/java/bwapi/BotWrapperAsync.java +++ b/src/main/java/bwapi/BotWrapperAsync.java @@ -1,7 +1,5 @@ package bwapi; -import java.util.concurrent.locks.ReentrantLock; - /** * Manages invocation of bot event handlers */ @@ -10,9 +8,7 @@ class BotWrapperAsync extends BotWrapper { private final FrameBuffer frameBuffer; private WrappedBuffer liveData; private Thread botThread; - private Throwable lastBotThrow; - private final ReentrantLock lastBotThrowLock = new ReentrantLock(); - private final ReentrantLock unsafeReadReadyLock = new ReentrantLock(); + private volatile Throwable lastBotThrow; private boolean unsafeReadReady = false; BotWrapperAsync(BWClientConfiguration configuration, BWEventListener eventListener) { @@ -29,7 +25,12 @@ class BotWrapperAsync extends BotWrapper { void startNewGame(WrappedBuffer liveData, PerformanceMetrics performanceMetrics) { frameBuffer.initialize(liveData, performanceMetrics); - super.startNewGame(liveData, performanceMetrics); + this.performanceMetrics = performanceMetrics; + ClientDataAsync clientDataAsync = new ClientDataAsync(liveData); + botGame = new Game(clientDataAsync); + botGame.setConfiguration(configuration); + botGame.botClientData().setBuffer(liveData); + gameOver = false; liveClientData.setBuffer(liveData); this.liveData = liveData; @@ -43,32 +44,6 @@ void startNewGame(WrappedBuffer liveData, PerformanceMetrics performanceMetrics) } - private boolean isUnsafeReadReady() { - unsafeReadReadyLock.lock(); - try { - return unsafeReadReady; - } - finally { - unsafeReadReadyLock.unlock(); - } - } - - private void setUnsafeReadReady(boolean value) { - unsafeReadReadyLock.lock(); - try { - unsafeReadReady = value; - } - finally { - unsafeReadReadyLock.unlock(); - } - frameBuffer.lockSize.lock(); - try { - frameBuffer.conditionSize.signalAll(); - } finally { - frameBuffer.lockSize.unlock(); - } - } - @Override void onFrame() { configuration.log("Main: onFrame asynchronous start"); @@ -89,9 +64,10 @@ void asyncOnFrame() { if (frameBuffer.empty()) { configuration.log("Main: Putting bot on live data"); botGame.botClientData().setBuffer(liveData); - setUnsafeReadReady(true); + unsafeReadReady = true; + frameBuffer.conditionSize.signalAll(); } else { - setUnsafeReadReady(false); + unsafeReadReady = false; } } finally { frameBuffer.lockSize.unlock(); @@ -112,16 +88,6 @@ void asyncOnFrame() { frameBuffer.lockSize.lock(); try { while (!frameBuffer.empty()) { - // Unsafe mode: Move the bot off of live data onto the frame buffer - // This is the unsafe step! - // We don't synchronize on calls which access the buffer - // (to avoid tens of thousands of synchronized calls per frame) - // so there's no guarantee of safety here. - if (configuration.getAsyncUnsafe() && frameBuffer.size() == 1) { - configuration.log("Main: Weaning bot off live data"); - botGame.botClientData().setBuffer(frameBuffer.peek()); - } - // Make bot exceptions fall through to the main thread. Throwable lastThrow = getLastBotThrow(); if (lastThrow != null) { @@ -135,6 +101,14 @@ void asyncOnFrame() { } else { long remainingNanos = endNanos - System.nanoTime(); if (remainingNanos <= 0) { + // Unsafe mode: Move the bot off of live data onto the frame buffer + // only when the frame times out and control is about to return to StarCraft. + // When the bot finishes on time, liveData remains frozen for the entire frame. + if (configuration.getAsyncUnsafe() && frameBuffer.size() == 1) { + configuration.log("Main: Weaning bot off live data"); + unsafeReadReady = false; + botGame.botClientData().setBuffer(frameBuffer.peek()); + } configuration.log("Main: Out of time in frame #" + frame); break; } @@ -164,10 +138,7 @@ void endGame() { } Throwable getLastBotThrow() { - lastBotThrowLock.lock(); - Throwable output = lastBotThrow; - lastBotThrowLock.unlock(); - return output; + return lastBotThrow; } private Thread createBotThread() { @@ -175,30 +146,26 @@ private Thread createBotThread() { try { configuration.log("Bot: Thread started"); while (!gameOver) { - - boolean doUnsafeRead = false; configuration.log("Bot: Ready for another frame"); performanceMetrics.getBotIdle().startTiming(); frameBuffer.lockSize.lock(); try { - doUnsafeRead = isUnsafeReadReady(); - while ( ! doUnsafeRead && frameBuffer.empty()) { + while (!unsafeReadReady && frameBuffer.empty()) { configuration.log("Bot: Waiting for a frame"); frameBuffer.conditionSize.awaitUninterruptibly(); - doUnsafeRead = isUnsafeReadReady(); + } + performanceMetrics.getBotIdle().stopTiming(); + + if (unsafeReadReady) { + configuration.log("Bot: Reading live frame"); + unsafeReadReady = false; + } else { + configuration.log("Bot: Peeking next frame from buffer"); + botGame.botClientData().setBuffer(frameBuffer.peek()); } } finally { frameBuffer.lockSize.unlock(); } - performanceMetrics.getBotIdle().stopTiming(); - - if (doUnsafeRead) { - configuration.log("Bot: Reading live frame"); - setUnsafeReadReady(false); - } else { - configuration.log("Bot: Peeking next frame from buffer"); - botGame.botClientData().setBuffer(frameBuffer.peek()); - } configuration.log("Bot: Handling events on frame #" + botGame.getFrameCount()); handleEvents(); @@ -210,9 +177,7 @@ private Thread createBotThread() { // Record the throw, // Then allow the thread to terminate silently. // The main thread will look for the stored throw. - lastBotThrowLock.lock(); lastBotThrow = throwable; - lastBotThrowLock.unlock(); // Awaken any threads waiting on bot progress while (!frameBuffer.empty()) { diff --git a/src/main/java/bwapi/ClientData.java b/src/main/java/bwapi/ClientData.java index 5c957b2..7cf4362 100644 --- a/src/main/java/bwapi/ClientData.java +++ b/src/main/java/bwapi/ClientData.java @@ -2,11 +2,14 @@ import com.sun.jna.Pointer; -final class ClientData { - private WrappedBuffer buffer; - private GameData gameData; +class ClientData { + WrappedBuffer buffer; + GameData gameData; ClientData() { - gameData = new ClientData.GameData(0); + gameData = createGameData(0); + } + GameData createGameData(int offset) { + return new ClientData.GameData(offset); } GameData gameData() { return gameData; @@ -17,6 +20,9 @@ void setBuffer(WrappedBuffer buffer) { void setPointer(Pointer pointer) { setBuffer(new WrappedBuffer(pointer, GameData.SIZE)); } + WrappedBuffer getStaticBuffer() { + return buffer; + } class UnitCommand { static final int SIZE = 24; private int myOffset; @@ -68,7 +74,7 @@ void setExtra(int value) { } class GameData { static final int SIZE = 33017048; - private int myOffset; + protected int myOffset; GameData(int myOffset) { this.myOffset = myOffset; } @@ -729,7 +735,7 @@ void setValue2(int value) { } class Position { static final int SIZE = 8; - private int myOffset; + protected int myOffset; Position(int myOffset) { this.myOffset = myOffset; } @@ -750,7 +756,7 @@ void setY(int value) { } class Event { static final int SIZE = 12; - private int myOffset; + protected int myOffset; Event(int myOffset) { this.myOffset = myOffset; } @@ -784,94 +790,94 @@ class RegionData { } int getId() { int offset = myOffset + 0; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setId(int value) { - buffer.putInt(myOffset + 0, value); + getStaticBuffer().putInt(myOffset + 0, value); } int islandID() { int offset = myOffset + 4; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setIslandID(int value) { - buffer.putInt(myOffset + 4, value); + getStaticBuffer().putInt(myOffset + 4, value); } int getCenter_x() { int offset = myOffset + 8; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setCenter_x(int value) { - buffer.putInt(myOffset + 8, value); + getStaticBuffer().putInt(myOffset + 8, value); } int getCenter_y() { int offset = myOffset + 12; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setCenter_y(int value) { - buffer.putInt(myOffset + 12, value); + getStaticBuffer().putInt(myOffset + 12, value); } int getPriority() { int offset = myOffset + 16; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setPriority(int value) { - buffer.putInt(myOffset + 16, value); + getStaticBuffer().putInt(myOffset + 16, value); } int getLeftMost() { int offset = myOffset + 20; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setLeftMost(int value) { - buffer.putInt(myOffset + 20, value); + getStaticBuffer().putInt(myOffset + 20, value); } int getRightMost() { int offset = myOffset + 24; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setRightMost(int value) { - buffer.putInt(myOffset + 24, value); + getStaticBuffer().putInt(myOffset + 24, value); } int getTopMost() { int offset = myOffset + 28; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setTopMost(int value) { - buffer.putInt(myOffset + 28, value); + getStaticBuffer().putInt(myOffset + 28, value); } int getBottomMost() { int offset = myOffset + 32; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setBottomMost(int value) { - buffer.putInt(myOffset + 32, value); + getStaticBuffer().putInt(myOffset + 32, value); } int getNeighborCount() { int offset = myOffset + 36; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setNeighborCount(int value) { - buffer.putInt(myOffset + 36, value); + getStaticBuffer().putInt(myOffset + 36, value); } int getNeighbors(int i) { int offset = myOffset + 40 + 4 * 1 * i; - return buffer.getInt(offset); + return getStaticBuffer().getInt(offset); } void setNeighbors(int i, int value) { - buffer.putInt(myOffset + 40 + 4 * 1 * i, value); + getStaticBuffer().putInt(myOffset + 40 + 4 * 1 * i, value); } boolean isAccessible() { int offset = myOffset + 1064; - return buffer.getByte(offset) != 0; + return getStaticBuffer().getByte(offset) != 0; } void setIsAccessible(boolean value) { - buffer.putByte(myOffset + 1064, (byte) (value ? 1 : 0)); + getStaticBuffer().putByte(myOffset + 1064, (byte) (value ? 1 : 0)); } boolean isHigherGround() { int offset = myOffset + 1065; - return buffer.getByte(offset) != 0; + return getStaticBuffer().getByte(offset) != 0; } void setIsHigherGround(boolean value) { - buffer.putByte(myOffset + 1065, (byte) (value ? 1 : 0)); + getStaticBuffer().putByte(myOffset + 1065, (byte) (value ? 1 : 0)); } } class ForceData { @@ -2002,4 +2008,3 @@ void setReplayID(int value) { } } } - diff --git a/src/main/java/bwapi/ClientDataAsync.java b/src/main/java/bwapi/ClientDataAsync.java new file mode 100644 index 0000000..88bb2ba --- /dev/null +++ b/src/main/java/bwapi/ClientDataAsync.java @@ -0,0 +1,370 @@ +package bwapi; + +/** + * Asynchronous client data that routes static information to the main/static shared buffer + * and dynamic information through pre-shifted slice views. + * Automatically generated by DumpToClient. + */ +class ClientDataAsync extends ClientData { + private WrappedBuffer staticBuffer; + private PackedWrappedBuffer staticPackedView; + PackedWrappedBuffer packedBuffer; + + ClientDataAsync() { + super(); + } + + ClientDataAsync(WrappedBuffer staticBuffer) { + super(); + setStaticBuffer(staticBuffer); + } + + void setStaticBuffer(WrappedBuffer staticBuffer) { + this.staticBuffer = staticBuffer; + this.staticPackedView = staticBuffer != null ? new PackedWrappedBuffer(staticBuffer) : null; + } + + @Override + void setBuffer(WrappedBuffer buffer) { + super.setBuffer(buffer); + if (buffer instanceof PackedWrappedBuffer) { + this.packedBuffer = (PackedWrappedBuffer) buffer; + } else if (buffer != null && buffer == this.staticBuffer) { + this.packedBuffer = this.staticPackedView; + } else { + this.packedBuffer = buffer != null ? new PackedWrappedBuffer(buffer) : null; + } + } + + @Override + WrappedBuffer getStaticBuffer() { + return staticBuffer != null ? staticBuffer : buffer; + } + + @Override + GameData createGameData(int offset) { + return new AsyncGameData(offset); + } + + class AsyncPosition extends Position { + AsyncPosition(int myOffset) { + super(myOffset); + } + @Override + int getX() { + int offset = myOffset + 0; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setX(int value) { + packedBuffer.slice3.putInt(myOffset + 0, value); + } + @Override + int getY() { + int offset = myOffset + 4; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setY(int value) { + packedBuffer.slice3.putInt(myOffset + 4, value); + } + } + + class AsyncEvent extends Event { + AsyncEvent(int myOffset) { + super(myOffset); + } + @Override + EventType getType() { + int offset = myOffset + 0; + return EventType.idToEnum[packedBuffer.slice3.getInt(offset)]; + } + @Override + void setType(EventType value) { + packedBuffer.slice3.putInt(myOffset + 0, value.id); + } + @Override + int getV1() { + int offset = myOffset + 4; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setV1(int value) { + packedBuffer.slice3.putInt(myOffset + 4, value); + } + @Override + int getV2() { + int offset = myOffset + 8; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setV2(int value) { + packedBuffer.slice3.putInt(myOffset + 8, value); + } + } + + class AsyncGameData extends GameData { + AsyncGameData(int myOffset) { + super(myOffset); + } + + @Override + int getGroundHeight(int i, int j) { + int offset = myOffset + 3447004 + 4 * 1 * j + 4 * 256 * i; + return getStaticBuffer().getInt(offset); + } + @Override + void setGetGroundHeight(int i, int j, int value) { + getStaticBuffer().putInt(myOffset + 3447004 + 4 * 1 * j + 4 * 256 * i, value); + } + @Override + boolean isWalkable(int i, int j) { + int offset = myOffset + 3709148 + 1 * 1 * j + 1 * 1024 * i; + return getStaticBuffer().getByte(offset) != 0; + } + @Override + void setIsWalkable(int i, int j, boolean value) { + getStaticBuffer().putByte(myOffset + 3709148 + 1 * 1 * j + 1 * 1024 * i, (byte) (value ? 1 : 0)); + } + @Override + boolean isBuildable(int i, int j) { + int offset = myOffset + 4757724 + 1 * 1 * j + 1 * 256 * i; + return getStaticBuffer().getByte(offset) != 0; + } + @Override + void setIsBuildable(int i, int j, boolean value) { + getStaticBuffer().putByte(myOffset + 4757724 + 1 * 1 * j + 1 * 256 * i, (byte) (value ? 1 : 0)); + } + @Override + boolean isVisible(int i, int j) { + int offset = myOffset + 4823260 + 1 * 1 * j + 1 * 256 * i; + return packedBuffer.slice2.getByte(offset) != 0; + } + @Override + void setIsVisible(int i, int j, boolean value) { + packedBuffer.slice2.putByte(myOffset + 4823260 + 1 * 1 * j + 1 * 256 * i, (byte) (value ? 1 : 0)); + } + @Override + boolean isExplored(int i, int j) { + int offset = myOffset + 4888796 + 1 * 1 * j + 1 * 256 * i; + return packedBuffer.slice2.getByte(offset) != 0; + } + @Override + void setIsExplored(int i, int j, boolean value) { + packedBuffer.slice2.putByte(myOffset + 4888796 + 1 * 1 * j + 1 * 256 * i, (byte) (value ? 1 : 0)); + } + @Override + boolean getHasCreep(int i, int j) { + int offset = myOffset + 4954332 + 1 * 1 * j + 1 * 256 * i; + return packedBuffer.slice2.getByte(offset) != 0; + } + @Override + void setHasCreep(int i, int j, boolean value) { + packedBuffer.slice2.putByte(myOffset + 4954332 + 1 * 1 * j + 1 * 256 * i, (byte) (value ? 1 : 0)); + } + @Override + boolean isOccupied(int i, int j) { + int offset = myOffset + 5019868 + 1 * 1 * j + 1 * 256 * i; + return packedBuffer.slice2.getByte(offset) != 0; + } + @Override + void setIsOccupied(int i, int j, boolean value) { + packedBuffer.slice2.putByte(myOffset + 5019868 + 1 * 1 * j + 1 * 256 * i, (byte) (value ? 1 : 0)); + } + @Override + short getMapTileRegionId(int i, int j) { + int offset = myOffset + 5085404 + 2 * 1 * j + 2 * 256 * i; + return getStaticBuffer().getShort(offset); + } + @Override + void setMapTileRegionId(int i, int j, short value) { + getStaticBuffer().putShort(myOffset + 5085404 + 2 * 1 * j + 2 * 256 * i, value); + } + @Override + short getMapSplitTilesMiniTileMask(int i) { + int offset = myOffset + 5216476 + 2 * 1 * i; + return getStaticBuffer().getShort(offset); + } + @Override + void setMapSplitTilesMiniTileMask(int i, short value) { + getStaticBuffer().putShort(myOffset + 5216476 + 2 * 1 * i, value); + } + @Override + short getMapSplitTilesRegion1(int i) { + int offset = myOffset + 5226476 + 2 * 1 * i; + return getStaticBuffer().getShort(offset); + } + @Override + void setMapSplitTilesRegion1(int i, short value) { + getStaticBuffer().putShort(myOffset + 5226476 + 2 * 1 * i, value); + } + @Override + short getMapSplitTilesRegion2(int i) { + int offset = myOffset + 5236476 + 2 * 1 * i; + return getStaticBuffer().getShort(offset); + } + @Override + void setMapSplitTilesRegion2(int i, short value) { + getStaticBuffer().putShort(myOffset + 5236476 + 2 * 1 * i, value); + } + @Override + int getRegionCount() { + int offset = myOffset + 5246476; + return getStaticBuffer().getInt(offset); + } + @Override + void setRegionCount(int value) { + getStaticBuffer().putInt(myOffset + 5246476, value); + } + @Override + RegionData getRegions(int i) { + int offset = myOffset + 5246480 + 1068 * 1 * i; + return new RegionData(offset); + } + @Override + int getStartLocationCount() { + int offset = myOffset + 10586480; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setStartLocationCount(int value) { + packedBuffer.slice3.putInt(myOffset + 10586480, value); + } + @Override + Position getStartLocations(int i) { + int offset = myOffset + 10586484 + 8 * 1 * i; + return new AsyncPosition(offset); + } + @Override + boolean isInGame() { + int offset = myOffset + 10586548; + return packedBuffer.slice3.getByte(offset) != 0; + } + @Override + void setIsInGame(boolean value) { + packedBuffer.slice3.putByte(myOffset + 10586548, (byte) (value ? 1 : 0)); + } + @Override + boolean isMultiplayer() { + int offset = myOffset + 10586549; + return packedBuffer.slice3.getByte(offset) != 0; + } + @Override + void setIsMultiplayer(boolean value) { + packedBuffer.slice3.putByte(myOffset + 10586549, (byte) (value ? 1 : 0)); + } + @Override + boolean isBattleNet() { + int offset = myOffset + 10586550; + return packedBuffer.slice3.getByte(offset) != 0; + } + @Override + void setIsBattleNet(boolean value) { + packedBuffer.slice3.putByte(myOffset + 10586550, (byte) (value ? 1 : 0)); + } + @Override + boolean isPaused() { + int offset = myOffset + 10586551; + return packedBuffer.slice3.getByte(offset) != 0; + } + @Override + void setIsPaused(boolean value) { + packedBuffer.slice3.putByte(myOffset + 10586551, (byte) (value ? 1 : 0)); + } + @Override + boolean isReplay() { + int offset = myOffset + 10586552; + return packedBuffer.slice3.getByte(offset) != 0; + } + @Override + void setIsReplay(boolean value) { + packedBuffer.slice3.putByte(myOffset + 10586552, (byte) (value ? 1 : 0)); + } + @Override + int getSelectedUnitCount() { + int offset = myOffset + 10586556; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setSelectedUnitCount(int value) { + packedBuffer.slice3.putInt(myOffset + 10586556, value); + } + @Override + int getSelectedUnits(int i) { + int offset = myOffset + 10586560 + 4 * 1 * i; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setSelectedUnits(int i, int value) { + packedBuffer.slice3.putInt(myOffset + 10586560 + 4 * 1 * i, value); + } + @Override + int getSelf() { + int offset = myOffset + 10586608; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setSelf(int value) { + packedBuffer.slice3.putInt(myOffset + 10586608, value); + } + @Override + int getEnemy() { + int offset = myOffset + 10586612; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setEnemy(int value) { + packedBuffer.slice3.putInt(myOffset + 10586612, value); + } + @Override + int getNeutral() { + int offset = myOffset + 10586616; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setNeutral(int value) { + packedBuffer.slice3.putInt(myOffset + 10586616, value); + } + @Override + int getEventCount() { + int offset = myOffset + 10586620; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setEventCount(int value) { + packedBuffer.slice3.putInt(myOffset + 10586620, value); + } + @Override + Event getEvents(int i) { + int offset = myOffset + 10586624 + 12 * 1 * i; + return new AsyncEvent(offset); + } + @Override + int getEventStringCount() { + int offset = myOffset + 10706624; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setEventStringCount(int value) { + packedBuffer.slice3.putInt(myOffset + 10706624, value); + } + @Override + String getEventStrings(int i) { + int offset = myOffset + 10706628 + 1 * 256 * i; + return packedBuffer.slice3.getString(offset, 256); + } + @Override + void setEventStrings(int i, String value) { + packedBuffer.slice3.putString(myOffset + 10706628 + 1 * 256 * i, 256, value); + } + @Override + int getStringCount() { + int offset = myOffset + 10962628; + return packedBuffer.slice3.getInt(offset); + } + @Override + void setStringCount(int value) { + packedBuffer.slice3.putInt(myOffset + 10962628, value); + } + } +} diff --git a/src/main/java/bwapi/FrameBuffer.java b/src/main/java/bwapi/FrameBuffer.java index d245f70..9cb3340 100644 --- a/src/main/java/bwapi/FrameBuffer.java +++ b/src/main/java/bwapi/FrameBuffer.java @@ -11,7 +11,22 @@ * Circular buffer of game states. */ class FrameBuffer { - private static final int BUFFER_SIZE = ClientData.GameData.SIZE; + static final int BUFFER_SIZE = PackedWrappedBuffer.BUFFER_SIZE; + + static final int INITIAL_UNIT_COUNT_OFFSET = 69648; + static final int UNITS_START = 69656; + static final int MAX_UNITS = 10000; + static final int UNITS_END = UNITS_START + MAX_UNITS * ClientData.UnitData.SIZE; + + static final int EVENT_COUNT_OFFSET = 10586620; + static final int EVENTS_START = 10586624; + static final int MAX_EVENTS = 10000; + static final int EVENTS_HEADER_SIZE = EVENTS_START - PackedWrappedBuffer.REGION_END; + + static final int EVENT_STRING_COUNT_OFFSET = 10706624; + static final int EVENT_STRING_SIZE = 256; + static final int MAX_EVENT_STRINGS = 1000; + private static final Unsafe unsafe = UnsafeTools.getUnsafe(); private WrappedBuffer liveData; @@ -20,9 +35,9 @@ class FrameBuffer { private final int capacity; private int stepGame = 0; private int stepBot = 0; + private int maxUnitCount = 0; private final ArrayList dataBuffer = new ArrayList<>(); - private final Lock lockWrite = new ReentrantLock(); final Lock lockSize = new ReentrantLock(); final Condition conditionSize = lockSize.newCondition(); @@ -30,7 +45,7 @@ class FrameBuffer { this.capacity = configuration.getAsyncFrameBufferCapacity(); this.configuration = configuration; while(dataBuffer.size() < capacity) { - dataBuffer.add(new WrappedBuffer(BUFFER_SIZE)); + dataBuffer.add(new PackedWrappedBuffer()); } } @@ -42,32 +57,33 @@ void initialize(WrappedBuffer liveData, PerformanceMetrics performanceMetrics) { this.performanceMetrics = performanceMetrics; stepGame = 0; stepBot = 0; + maxUnitCount = 0; } /** * @return The number of frames currently buffered ahead of the bot's current frame */ - synchronized int framesBuffered() { - return stepGame - stepBot; + int framesBuffered() { + lockSize.lock(); + try { + return stepGame - stepBot; + } finally { + lockSize.unlock(); + } } /** * @return Number of frames currently stored in the buffer */ int size() { - lockSize.lock(); - try { - return framesBuffered(); - } finally { - lockSize.unlock(); - } + return framesBuffered(); } /** * @return Whether the frame buffer is empty and has no frames available for the bot to consume. */ boolean empty() { - return size() <= 0; + return framesBuffered() <= 0; } /** @@ -75,12 +91,7 @@ boolean empty() { * When the frame buffer is full, JBWAPI must wait for the bot to complete a frame before returning control to StarCraft. */ boolean full() { - lockSize.lock(); - try { - return framesBuffered() >= capacity; - } finally { - lockSize.unlock(); - } + return framesBuffered() >= capacity; } private int indexGame() { @@ -95,39 +106,38 @@ private int indexBot() { * Copy dataBuffer from shared memory into the head of the frame buffer. */ void enqueueFrame() { - lockWrite.lock(); + lockSize.lock(); try { - lockSize.lock(); - try { - while (full()) { - configuration.log("Main: Waiting for frame buffer capacity"); - performanceMetrics.getIntentionallyBlocking().startTiming(); - conditionSize.awaitUninterruptibly(); - } - performanceMetrics.getIntentionallyBlocking().stopTiming(); - } finally { lockSize.unlock(); }; - - // For the first frame of the game, populate all buffers completely - // This is to ensure all buffers have access to immutable data like regions/walkability/buildability - // Afterwards, we want to shorten this process by only copying important and mutable data - if (stepGame == 0) { - for (WrappedBuffer frameBuffer : dataBuffer) { - copyBuffer(liveData, frameBuffer, true); - } - } else { - performanceMetrics.getCopyingToBuffer().time(() -> { - WrappedBuffer dataTarget = dataBuffer.get(indexGame()); - copyBuffer(liveData, dataTarget, false); - }); + while (stepGame - stepBot >= capacity) { + configuration.log("Main: Waiting for frame buffer capacity"); + performanceMetrics.getIntentionallyBlocking().startTiming(); + conditionSize.awaitUninterruptibly(); } + performanceMetrics.getIntentionallyBlocking().stopTiming(); + } finally { + lockSize.unlock(); + } + + // Copy dynamic game information into the current frame buffer slot. + // Static information (e.g. walkability/buildability/regions) is accessed + // directly from the main shared memory buffer by ClientDataAsync. + if (stepGame == 0) { + copyBuffer(liveData, dataBuffer.get(indexGame())); + } else { + performanceMetrics.getCopyingToBuffer().time(() -> { + WrappedBuffer dataTarget = dataBuffer.get(indexGame()); + copyBuffer(liveData, dataTarget); + }); + } - lockSize.lock(); - try { - performanceMetrics.getFrameBufferSize().record(framesBuffered()); - ++stepGame; - conditionSize.signalAll(); - } finally { lockSize.unlock(); } - } finally { lockWrite.unlock(); } + lockSize.lock(); + try { + performanceMetrics.getFrameBufferSize().record(stepGame - stepBot); + ++stepGame; + conditionSize.signalAll(); + } finally { + lockSize.unlock(); + } } /** @@ -136,9 +146,11 @@ void enqueueFrame() { WrappedBuffer peek() { lockSize.lock(); try { - while(empty()) conditionSize.awaitUninterruptibly(); + while (stepGame - stepBot <= 0) conditionSize.awaitUninterruptibly(); return dataBuffer.get(indexBot()); - } finally { lockSize.unlock(); } + } finally { + lockSize.unlock(); + } } /** @@ -147,51 +159,98 @@ WrappedBuffer peek() { void dequeue() { lockSize.lock(); try { - while(empty()) conditionSize.awaitUninterruptibly(); + while (stepGame - stepBot <= 0) conditionSize.awaitUninterruptibly(); ++stepBot; conditionSize.signalAll(); - } finally { lockSize.unlock(); } + } finally { + lockSize.unlock(); + } } /** + * The speed at which we copy data into the frame buffer is a major cost of JBWAPI's asynchronous operation. + * Copy times observed in the wild for the complete 33 MB shared memory buffer used to range from 2.6ms - 19ms + * and are prone to large amounts of variance. * - * @param source Address to copy from - * @param destination Address to copy to - * @param size Number of bytes to copy + * To minimize both memory footprint and copy time, we: + * 1. Omit static map and region data (groundHeight, isWalkable, isBuildable, regions), which are read directly + * from the main shared memory buffer via ClientDataAsync. + * 2. Omit client-to-server output buffers (strings, shapes, commands, unitCommands), which are flushed directly + * to live shared memory by SideEffectQueue at the end of the frame. + * 3. Pack the remaining 3 dynamic ranges into a 4.08 MB PackedWrappedBuffer (using pre-shifted slice views so + * static BWAPI field offsets work without runtime branching). + * 4. Within those ranges, only copy the currently active prefixes of units[0..maxUnitCount), + * events[0..eventCount), and eventStrings[0..eventStringCount), skipping their unused tails. + * (Further sub-slicing the ~349 KB fixed base, e.g. mapWidth slicing on Slice 2's 4 tile tables or skipping + * unused players/unitArray/nukeDots, only saves ~5-40 us and isn't worth splitting 5 contiguous copies into 10.) + * + * Approximate bytes copied per frame (Fastest game speed ~= 23.8 frames/sec, ~1,428 frames/minute): + * - Fixed base (header, forces, players, bullets, dynamic tile flags): ~349 KB (0.35 MB) + * - Game start (Frame 0, ~300 initial units): ~450 KB (0.45 MB) + * - Mid-game (~8-10 min / ~7,500 frames, ~1,000 cumulative units): ~685 KB (0.69 MB) + * - Late-game (~20+ min / ~20,000 frames, ~2,500 cumulative units): ~1.19 MB (max cap at 10,000 units: 4.08 MB) + * + * Estimated per-frame copy times across hardware and CPU core counts: + * +-----------------------------------------------+---------------+---------------+---------------+---------------+ + * | Environment / Hardware & Core Count | Start (450KB) | Mid (685KB) | Late (1.19MB) | Max (4.08MB) | + * +-----------------------------------------------+---------------+---------------+---------------+---------------+ + * | Weak / Virtualized (1-vCPU Docker / shared VM | 0.12 - 0.35ms | 0.18 - 0.50ms | 0.30 - 0.85ms | 1.00 - 2.80ms | + * | ~3-5 GB/s; Wine, Client & Bot share 1 core) | | | | | + * +-----------------------------------------------+---------------+---------------+---------------+---------------+ + * | Normal Desktop / Tourney VM (2-4 cores, DDR4, | 0.04 - 0.08ms | 0.06 - 0.12ms | 0.10 - 0.20ms | 0.30 - 0.60ms | + * | ~12 GB/s; Copy runs in parallel with Bot) | | | | | + * +-----------------------------------------------+---------------+---------------+---------------+---------------+ + * | Fast Modern CPU (4+ cores, DDR5, ~30+ GB/s; | 0.01 - 0.03ms | 0.02 - 0.05ms | 0.04 - 0.08ms | 0.12 - 0.25ms | + * | fits in L2/L3, finishes before Bot wakes) | | | | | + * +-----------------------------------------------+---------------+---------------+---------------+---------------+ */ - private void copyBuffer(WrappedBuffer source, WrappedBuffer destination, long offset, int size) { - long addressSource = source.getAddress() + offset; - long addressDestination = destination.getAddress() + offset; - unsafe.copyMemory(addressSource, addressDestination, size); + void copyBuffer(WrappedBuffer source, WrappedBuffer destination) { + long srcAddr = source.getAddress(); + long dstAddr = destination.getAddress(); + + int eventCount = clamp(source.getInt(EVENT_COUNT_OFFSET), 0, MAX_EVENTS); + int eventStringCount = clamp(source.getInt(EVENT_STRING_COUNT_OFFSET), 0, MAX_EVENT_STRINGS); + int activeUnits = updateMaxUnitCount(source, eventCount); + + // Slice 1a (dynamic size): header, forces, players, initialUnitCount, and active units [0 .. activeUnits) + copySlice(srcAddr, dstAddr, 0, 0, UNITS_START + activeUnits * ClientData.UnitData.SIZE); + // Slice 1b (static size): unitArray, bullets, nukeDots, frameCount, and other post-unit Slice 1 fields + copySlice(srcAddr, dstAddr, UNITS_END, 0, PackedWrappedBuffer.STATICTILES_START - UNITS_END); + // Slice 2 (static size): tile visibility, exploration, creep, and occupation flags + copySlice(srcAddr, dstAddr, PackedWrappedBuffer.STATICTILES_END, PackedWrappedBuffer.DELTA_RANGE2, PackedWrappedBuffer.DYNAMIC_SLICE2_SIZE); + // Slice 3a (dynamic size): startLocations, game state flags, selectedUnits, self/enemy/neutral, eventCount, and active events [0 .. eventCount) + copySlice(srcAddr, dstAddr, PackedWrappedBuffer.REGION_END, PackedWrappedBuffer.DELTA_RANGE3, EVENTS_HEADER_SIZE + eventCount * ClientData.Event.SIZE); + // Slice 3b (dynamic size): eventStringCount and active eventStrings [0 .. eventStringCount) + copySlice(srcAddr, dstAddr, EVENT_STRING_COUNT_OFFSET, PackedWrappedBuffer.DELTA_RANGE3, 4 + eventStringCount * EVENT_STRING_SIZE); } - void copyBuffer(WrappedBuffer source, WrappedBuffer destination, boolean copyEverything) { - /* - The speed at which we copy data into the frame buffer is a major cost of JBWAPI's asynchronous operation. - Copy times observed in the wild for the complete buffer usually range from 2.6ms - 19ms - but are prone to large amounts of variance. + int getMaxUnitCount() { + return maxUnitCount; + } - The normal Java way to execute this copy is via ByteBuffer.put(), which has reasonably good performance characteristics. - */ + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } - if (copyEverything) { - copyBuffer(source, destination, 0, FrameBuffer.BUFFER_SIZE); - } else { - // After the buffer has been filled the first time, - // we can omit copying blocks of data which are unused or which don't change after game start. - // These blocks account for *most* of the 33MB shared memory, - // so omitting them drastically reduces the copy duration - final int STATICTILES_START = 3447004; // getGroundHeight, isWalkable, isBuildable - final int STATICTILES_END = 4823260; - final int REGION_START = 5085404; // getMapTileRegionId, ..., getRegions - final int REGION_END = 10586480; - final int STRINGSSHAPES_START = 10962632; // getStringCount, ... getShapes - final int STRINGSHAPES_END = 32242636; - final int UNITFINDER_START = 32962644; - copyBuffer(source, destination, 0, STATICTILES_START); - copyBuffer(source, destination, STATICTILES_END, REGION_START - STATICTILES_END); - copyBuffer(source, destination, REGION_END, STRINGSSHAPES_START - REGION_END); - copyBuffer(source, destination, STRINGSHAPES_END, UNITFINDER_START - STRINGSHAPES_END); + private int updateMaxUnitCount(WrappedBuffer source, int eventCount) { + int initialUnits = clamp(source.getInt(INITIAL_UNIT_COUNT_OFFSET), 0, MAX_UNITS); + if (initialUnits > maxUnitCount) { + maxUnitCount = initialUnits; } + + for (int i = 0; i < eventCount; i++) { + int eventOffset = EVENTS_START + i * ClientData.Event.SIZE; + if (source.getInt(eventOffset) == EventType.UnitDiscover.id) { + int unitId = source.getInt(eventOffset + 4); + if (unitId >= 0 && unitId < MAX_UNITS && unitId + 1 > maxUnitCount) { + maxUnitCount = unitId + 1; + } + } + } + return maxUnitCount; + } + + private static void copySlice(long srcAddr, long dstAddr, int srcOffset, int delta, int length) { + unsafe.copyMemory(srcAddr + srcOffset, dstAddr + srcOffset - delta, length); } } diff --git a/src/main/java/bwapi/Game.java b/src/main/java/bwapi/Game.java index 6e853ae..bbea918 100644 --- a/src/main/java/bwapi/Game.java +++ b/src/main/java/bwapi/Game.java @@ -106,7 +106,11 @@ public class Game { final SideEffectQueue sideEffects = new SideEffectQueue(); Game() { - clientData = new ClientData(); + this(new ClientData()); + } + + Game(ClientData clientData) { + this.clientData = clientData; } void setConfiguration(BWClientConfiguration configuration) { diff --git a/src/main/java/bwapi/PackedWrappedBuffer.java b/src/main/java/bwapi/PackedWrappedBuffer.java new file mode 100644 index 0000000..c9eaa1f --- /dev/null +++ b/src/main/java/bwapi/PackedWrappedBuffer.java @@ -0,0 +1,46 @@ +package bwapi; + +/** + * Packed off-heap buffer for asynchronous frame slots that stores only dynamic game data slices + * and exposes pre-shifted slice views so static BWAPI offsets work without runtime branching. + */ +class PackedWrappedBuffer extends WrappedBuffer { + static final int STATICTILES_START = 3447004; + static final int STATICTILES_END = 4823260; + static final int REGION_START = 5085404; + static final int REGION_END = 10586480; + static final int STRINGSSHAPES_START = 10962632; + + static final int DYNAMIC_SLICE1_SIZE = STATICTILES_START; + static final int DYNAMIC_SLICE2_SIZE = REGION_START - STATICTILES_END; + static final int DYNAMIC_SLICE3_SIZE = STRINGSSHAPES_START - REGION_END; + + static final int DEST_SLICE1_OFFSET = 0; + static final int DEST_SLICE2_OFFSET = DYNAMIC_SLICE1_SIZE; + static final int DEST_SLICE3_OFFSET = DEST_SLICE2_OFFSET + DYNAMIC_SLICE2_SIZE; + + static final int BUFFER_SIZE = DEST_SLICE3_OFFSET + DYNAMIC_SLICE3_SIZE; + + static final int DELTA_RANGE2 = STATICTILES_END - STATICTILES_START; + static final int DELTA_RANGE3 = DELTA_RANGE2 + (REGION_END - REGION_START); + + final WrappedBuffer slice2; + final WrappedBuffer slice3; + + PackedWrappedBuffer() { + this(BUFFER_SIZE); + } + + PackedWrappedBuffer(final int size) { + super(size); + long addr = getAddress(); + this.slice2 = new WrappedBuffer(getBuffer(), addr - DELTA_RANGE2); + this.slice3 = new WrappedBuffer(getBuffer(), addr - DELTA_RANGE3); + } + + PackedWrappedBuffer(final WrappedBuffer unpacked) { + super(null, 0L); + this.slice2 = unpacked; + this.slice3 = unpacked; + } +} diff --git a/src/main/java/bwapi/SideEffectQueue.java b/src/main/java/bwapi/SideEffectQueue.java index 8dc49b8..ee8b3ab 100644 --- a/src/main/java/bwapi/SideEffectQueue.java +++ b/src/main/java/bwapi/SideEffectQueue.java @@ -8,6 +8,7 @@ class SideEffectQueue { private ArrayList queue = new ArrayList<>(); + private ArrayList flushQueue = new ArrayList<>(); /** * Includes a side effect to be sent back to BWAPI in the future. @@ -25,8 +26,19 @@ synchronized void enqueue(SideEffect sideEffect) { * @param liveGameData * The live game frame's data, using the BWAPI shared memory. */ - synchronized void flushTo(ClientData.GameData liveGameData) { - queue.forEach(x -> x.apply(liveGameData)); - queue.clear(); + void flushTo(ClientData.GameData liveGameData) { + final ArrayList toFlush; + synchronized (this) { + if (queue.isEmpty()) { + return; + } + toFlush = queue; + queue = flushQueue; + flushQueue = toFlush; + } + for (int i = 0, size = toFlush.size(); i < size; i++) { + toFlush.get(i).apply(liveGameData); + } + toFlush.clear(); } } diff --git a/src/main/java/bwapi/WrappedBuffer.java b/src/main/java/bwapi/WrappedBuffer.java index fa9fe80..7f4f6a0 100644 --- a/src/main/java/bwapi/WrappedBuffer.java +++ b/src/main/java/bwapi/WrappedBuffer.java @@ -24,6 +24,11 @@ class WrappedBuffer { this.address = Pointer.nativeValue(pointer); } + WrappedBuffer(final ByteBuffer buffer, final long address) { + this.buffer = buffer; + this.address = address; + } + byte getByte(final int offset) { return unsafe.getByte(address + offset); } diff --git a/src/test/java/DumpToClient.java b/src/test/java/DumpToClient.java index 16ef449..636da70 100644 --- a/src/test/java/DumpToClient.java +++ b/src/test/java/DumpToClient.java @@ -1,6 +1,7 @@ import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; @@ -8,8 +9,10 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -87,195 +90,321 @@ public static void main(String[] args) throws IOException { } } + Struct gameData = structs.get("GameData"); + Map asyncSliceStructs = new LinkedHashMap<>(); + for (Variable v : gameData.variables) { + if (v.type == Type.STRUCT) { + if (v.offset >= 4823260 && v.offset < 5085404) { + asyncSliceStructs.put(v.structRef, "packedBuffer.slice2"); + } else if (v.offset >= 10586480 && v.offset < 10962632) { + asyncSliceStructs.put(v.structRef, "packedBuffer.slice3"); + } + } + } + Set protectedOffsetStructs = asyncSliceStructs.keySet().stream() + .map(s -> s.name) + .collect(Collectors.toSet()); + protectedOffsetStructs.add("GameData"); + StringWriter sw = new StringWriter(); try (PrintWriter out = new PrintWriter(sw)) { out.println("package bwapi;"); out.println(""); - out.println("final class ClientData {"); - out.println(" private WrappedBuffer buffer;"); - out.println(" private GameData gameData;"); + out.println("import com.sun.jna.Pointer;"); + out.println(""); + out.println("class ClientData {"); + out.println(" WrappedBuffer buffer;"); + out.println(" GameData gameData;"); out.println(" ClientData() {"); - out.println(" gameData = new ClientData.GameData(0);"); + out.println(" gameData = createGameData(0);"); + out.println(" }"); + out.println(" GameData createGameData(int offset) {"); + out.println(" return new ClientData.GameData(offset);"); out.println(" }"); out.println(" GameData gameData() {"); out.println(" return gameData;"); out.println(" }"); out.println(" void setBuffer(WrappedBuffer buffer) {"); - out.println(" this.buffer = buffer;"); + out.println(" this.buffer = buffer;"); out.println(" }"); out.println(" void setPointer(Pointer pointer) {"); out.println(" setBuffer(new WrappedBuffer(pointer, GameData.SIZE));"); out.println(" }"); - out.println(""); + out.println(" WrappedBuffer getStaticBuffer() {"); + out.println(" return buffer;"); + out.println(" }"); structs.values().forEach(s -> { + String bufName = s.name.equals("RegionData") ? "getStaticBuffer()" : "buffer"; out.printf(" class %s {\n", s.name); out.printf(" static final int SIZE = %d;\n", s.size); - out.println(" private int myOffset;"); + String access = protectedOffsetStructs.contains(s.name) ? "protected" : "private"; + out.printf(" %s int myOffset;\n", access); out.printf(" %s(int myOffset) {\n", s.name); out.println(" this.myOffset = myOffset;"); out.println(" }"); - s.variables.forEach(v -> { - out.print(" "); - switch (v.type) { - case INT: - out.print("int"); - break; - case CHAR: - if (v.arraySizes.isEmpty()) { - out.print("char"); - } else { - out.print("String"); - } - break; - case ENUM: - out.print(v.enumName); - break; - case DOUBLE: - out.print("double"); - break; - case STRUCT: - out.print(v.structRef.name); - break; - case BOOLEAN: - out.print("boolean"); - break; - case UNSIGNED_INT: - out.print("int"); - break; - case UNSIGNED_SHORT: - out.print("short"); - break; - } - String camelCaseName = - v.name.substring(0, 1).toUpperCase() + v.name.substring(1); - if (v.name.startsWith("is") || v.name.startsWith("get")) { - out.printf(" %s(", v.name); - } else { - out.printf(" get%s(", camelCaseName); - } - List params = new ArrayList<>(); - String offsetString; - int arrayIndices = v.arraySizes.size(); - if (v.type == Type.CHAR) { - arrayIndices--; - } - if (arrayIndices > 0) { - List index = new ArrayList<>(); - int offset = 1; - for (int i = v.arraySizes.size() - 1; i >= 0; i--) { - int size = 1; - switch (v.type) { - case UNSIGNED_SHORT: - size *= 2; - break; - case UNSIGNED_INT: - case INT: - case ENUM: - size *= 4; - break; - case STRUCT: - size *= v.structRef.size; - break; - } - if (i < arrayIndices) { - params.add("int " + (char) ('i' + arrayIndices - 1 - i)); - index.add( - size + " * " + offset + " * " + (char) ('i' + i)); - } - offset *= v.arraySizes.get(i); - } - offsetString = "myOffset + " + v.offset + " + " + String.join(" + ", index); - } else { - offsetString = "myOffset + " + v.offset; - } - String paramString = String.join(", ", params); - out.printf("%s) {\n", paramString); - out.printf(" int offset = %s;\n", offsetString); - out.print(" return "); - switch (v.type) { - case UNSIGNED_INT: - case INT: - out.print("buffer.getInt(offset)"); - break; - case CHAR: - out.printf("buffer.getString(offset, %d)", - v.arraySizes.get(v.arraySizes.size() - 1)); - break; - case ENUM: - out.print(v.enumName + ".idToEnum[buffer.getInt(offset)]"); - break; - case DOUBLE: - out.print("buffer.getDouble(offset)"); - break; - case STRUCT: - out.printf("new %s(offset)", v.structRef.name); - break; - case BOOLEAN: - out.print("buffer.getByte(offset) != 0"); - break; - case UNSIGNED_SHORT: - out.print("buffer.getShort(offset)"); - break; - } - out.println(";"); - out.println(" }"); - - if (v.type != Type.STRUCT && (v.type != Type.CHAR || !v.arraySizes.isEmpty())) { - if (paramString.isEmpty()) { - out.printf(" void set%s(", camelCaseName); - } else { - out.printf(" void set%s(%s, ", camelCaseName, paramString); - } - switch (v.type) { - case INT: - case UNSIGNED_INT: - out.println("int value) {"); - out.printf(" buffer.putInt(%s, value);\n", - offsetString); - break; - case ENUM: - out.printf("%s value) {\n", v.enumName); - out.printf(" buffer.putInt(%s, value.id);\n", - offsetString); - break; - case UNSIGNED_SHORT: - out.println("short value) {"); - out.printf(" buffer.putShort(%s, value);\n", - offsetString); - break; - case DOUBLE: - out.println("double value) {"); - out.printf(" buffer.putDouble(%s, value);\n", - offsetString); - break; - case BOOLEAN: - out.println("boolean value) {"); - out.printf( - " buffer.putByte(%s, (byte) (value ? 1 : 0));\n", - offsetString); - break; - case CHAR: - out.println("String value) {"); - int maxLength = v.arraySizes.get(v.arraySizes.size() - 1); - out.printf( - " buffer.putString(%s, %d, value);\n", - offsetString, - maxLength); - break; - } - out.println(" }"); - } - }); + s.variables.forEach(v -> generateVariable(out, v, bufName, false, false)); out.println(" }"); }); out.println("}"); } + String clientDataText = sw.toString().replace("\r\n", "\n").replace("\n", "\r\n"); Files.write(Paths.get("src/main/java/bwapi/ClientData.java"), - Collections.singleton(sw.toString()), + clientDataText.getBytes(StandardCharsets.UTF_8), + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); + + StringWriter swAsync = new StringWriter(); + try (PrintWriter out = new PrintWriter(swAsync)) { + out.println("package bwapi;"); + out.println(""); + out.println("/**"); + out.println(" * Asynchronous client data that routes static information to the main/static shared buffer"); + out.println(" * and dynamic information through pre-shifted slice views."); + out.println(" * Automatically generated by DumpToClient."); + out.println(" */"); + out.println("class ClientDataAsync extends ClientData {"); + out.println(" private WrappedBuffer staticBuffer;"); + out.println(" private PackedWrappedBuffer staticPackedView;"); + out.println(" PackedWrappedBuffer packedBuffer;"); + out.println(""); + out.println(" ClientDataAsync() {"); + out.println(" super();"); + out.println(" }"); + out.println(""); + out.println(" ClientDataAsync(WrappedBuffer staticBuffer) {"); + out.println(" super();"); + out.println(" setStaticBuffer(staticBuffer);"); + out.println(" }"); + out.println(""); + out.println(" void setStaticBuffer(WrappedBuffer staticBuffer) {"); + out.println(" this.staticBuffer = staticBuffer;"); + out.println(" this.staticPackedView = staticBuffer != null ? new PackedWrappedBuffer(staticBuffer) : null;"); + out.println(" }"); + out.println(""); + out.println(" @Override"); + out.println(" void setBuffer(WrappedBuffer buffer) {"); + out.println(" super.setBuffer(buffer);"); + out.println(" if (buffer instanceof PackedWrappedBuffer) {"); + out.println(" this.packedBuffer = (PackedWrappedBuffer) buffer;"); + out.println(" } else if (buffer != null && buffer == this.staticBuffer) {"); + out.println(" this.packedBuffer = this.staticPackedView;"); + out.println(" } else {"); + out.println(" this.packedBuffer = buffer != null ? new PackedWrappedBuffer(buffer) : null;"); + out.println(" }"); + out.println(" }"); + out.println(""); + out.println(" @Override"); + out.println(" WrappedBuffer getStaticBuffer() {"); + out.println(" return staticBuffer != null ? staticBuffer : buffer;"); + out.println(" }"); + out.println(""); + out.println(" @Override"); + out.println(" GameData createGameData(int offset) {"); + out.println(" return new AsyncGameData(offset);"); + out.println(" }"); + out.println(""); + + for (Map.Entry entry : asyncSliceStructs.entrySet()) { + Struct s = entry.getKey(); + String sliceBuf = entry.getValue(); + out.printf(" class Async%s extends %s {\n", s.name, s.name); + out.printf(" Async%s(int myOffset) {\n", s.name); + out.println(" super(myOffset);"); + out.println(" }"); + s.variables.forEach(v -> generateVariable(out, v, sliceBuf, true, false)); + out.println(" }"); + out.println(""); + } + + out.println(" class AsyncGameData extends GameData {"); + out.println(" AsyncGameData(int myOffset) {"); + out.println(" super(myOffset);"); + out.println(" }"); + out.println(""); + + for (Variable v : gameData.variables) { + boolean isStaticTile = v.offset >= 3447004 && v.offset < 4823260; + boolean isDynamicTile = v.offset >= 4823260 && v.offset < 5085404; + boolean isStaticRegion = v.offset >= 5085404 && v.offset < 10586480; + boolean isDynamicGame = v.offset >= 10586480 && v.offset < 10962632; + + if (isStaticTile || isStaticRegion) { + generateVariable(out, v, "getStaticBuffer()", true, false); + } else if (isDynamicTile) { + generateVariable(out, v, "packedBuffer.slice2", true, true); + } else if (isDynamicGame) { + generateVariable(out, v, "packedBuffer.slice3", true, true); + } + } + out.println(" }"); + out.println("}"); + } + String clientDataAsyncText = swAsync.toString().replace("\r\n", "\n"); + Files.write(Paths.get("src/main/java/bwapi/ClientDataAsync.java"), + clientDataAsyncText.getBytes(StandardCharsets.UTF_8), StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); } + private static void generateVariable(PrintWriter out, Variable v, String bufName, boolean isOverride, boolean useAsyncStruct) { + if (isOverride) { + out.println(" @Override"); + } + out.print(" "); + switch (v.type) { + case INT: + out.print("int"); + break; + case CHAR: + if (v.arraySizes.isEmpty()) { + out.print("char"); + } else { + out.print("String"); + } + break; + case ENUM: + out.print(v.enumName); + break; + case DOUBLE: + out.print("double"); + break; + case STRUCT: + out.print(v.structRef.name); + break; + case BOOLEAN: + out.print("boolean"); + break; + case UNSIGNED_INT: + out.print("int"); + break; + case UNSIGNED_SHORT: + out.print("short"); + break; + } + String camelCaseName = + v.name.substring(0, 1).toUpperCase() + v.name.substring(1); + if (v.name.startsWith("is") || v.name.startsWith("get")) { + out.printf(" %s(", v.name); + } else { + out.printf(" get%s(", camelCaseName); + } + List params = new ArrayList<>(); + String offsetString; + int arrayIndices = v.arraySizes.size(); + if (v.type == Type.CHAR) { + arrayIndices--; + } + if (arrayIndices > 0) { + List index = new ArrayList<>(); + int offset = 1; + for (int i = v.arraySizes.size() - 1; i >= 0; i--) { + int size = 1; + switch (v.type) { + case UNSIGNED_SHORT: + size *= 2; + break; + case UNSIGNED_INT: + case INT: + case ENUM: + size *= 4; + break; + case STRUCT: + size *= v.structRef.size; + break; + } + if (i < arrayIndices) { + params.add("int " + (char) ('i' + arrayIndices - 1 - i)); + index.add( + size + " * " + offset + " * " + (char) ('i' + i)); + } + offset *= v.arraySizes.get(i); + } + offsetString = "myOffset + " + v.offset + " + " + String.join(" + ", index); + } else { + offsetString = "myOffset + " + v.offset; + } + String paramString = String.join(", ", params); + out.printf("%s) {\n", paramString); + out.printf(" int offset = %s;\n", offsetString); + out.print(" return "); + switch (v.type) { + case UNSIGNED_INT: + case INT: + out.printf("%s.getInt(offset)", bufName); + break; + case CHAR: + out.printf("%s.getString(offset, %d)", + bufName, v.arraySizes.get(v.arraySizes.size() - 1)); + break; + case ENUM: + out.printf("%s.idToEnum[%s.getInt(offset)]", v.enumName, bufName); + break; + case DOUBLE: + out.printf("%s.getDouble(offset)", bufName); + break; + case STRUCT: + String structCtor = useAsyncStruct ? "Async" + v.structRef.name : v.structRef.name; + out.printf("new %s(offset)", structCtor); + break; + case BOOLEAN: + out.printf("%s.getByte(offset) != 0", bufName); + break; + case UNSIGNED_SHORT: + out.printf("%s.getShort(offset)", bufName); + break; + } + out.println(";"); + out.println(" }"); + + if (v.type != Type.STRUCT && (v.type != Type.CHAR || !v.arraySizes.isEmpty())) { + if (isOverride) { + out.println(" @Override"); + } + if (paramString.isEmpty()) { + out.printf(" void set%s(", camelCaseName); + } else { + out.printf(" void set%s(%s, ", camelCaseName, paramString); + } + switch (v.type) { + case INT: + case UNSIGNED_INT: + out.println("int value) {"); + out.printf(" %s.putInt(%s, value);\n", + bufName, offsetString); + break; + case ENUM: + out.printf("%s value) {\n", v.enumName); + out.printf(" %s.putInt(%s, value.id);\n", + bufName, offsetString); + break; + case UNSIGNED_SHORT: + out.println("short value) {"); + out.printf(" %s.putShort(%s, value);\n", + bufName, offsetString); + break; + case DOUBLE: + out.println("double value) {"); + out.printf(" %s.putDouble(%s, value);\n", + bufName, offsetString); + break; + case BOOLEAN: + out.println("boolean value) {"); + out.printf( + " %s.putByte(%s, (byte) (value ? 1 : 0));\n", + bufName, offsetString); + break; + case CHAR: + out.println("String value) {"); + int maxLength = v.arraySizes.get(v.arraySizes.size() - 1); + out.printf( + " %s.putString(%s, %d, value);\n", + bufName, offsetString, + maxLength); + break; + } + out.println(" }"); + } + } + enum Type { STRUCT, BOOLEAN, diff --git a/src/test/java/bwapi/ClientDataAsyncTest.java b/src/test/java/bwapi/ClientDataAsyncTest.java new file mode 100644 index 0000000..2f67450 --- /dev/null +++ b/src/test/java/bwapi/ClientDataAsyncTest.java @@ -0,0 +1,259 @@ +package bwapi; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ClientDataAsyncTest { + + @Test + public void retainedViewsSurviveLiveAndPackedHandoffs() { + WrappedBuffer live = new WrappedBuffer(ClientData.GameData.SIZE); + PackedWrappedBuffer packed = new PackedWrappedBuffer(ClientData.GameData.SIZE); + ClientData source = new ClientData(); + source.setBuffer(live); + source.gameData().setEventCount(1); + source.gameData().getEvents(0).setType(EventType.MatchFrame); + source.gameData().getStartLocations(0).setX(123); + new FrameBuffer(new BWClientConfiguration.Builder().withAsyncFrameBufferCapacity(1).build()) + .copyBuffer(live, packed); + + ClientDataAsync data = new ClientDataAsync(live); + data.setBuffer(live); + ClientData.Event event = data.gameData().getEvents(0); + ClientData.Position position = data.gameData().getStartLocations(0); + data.setBuffer(packed); + assertEquals(EventType.MatchFrame, event.getType()); + assertEquals(123, position.getX()); + + event.setType(EventType.MatchEnd); + position.setX(789); + ClientData.Event packedEvent = data.gameData().getEvents(0); + data.setBuffer(live); + assertEquals(EventType.MatchFrame, packedEvent.getType()); + assertEquals(123, position.getX()); + data.setBuffer(packed); + assertEquals(EventType.MatchEnd, event.getType()); + assertEquals(789, position.getX()); + } + + @Test + public void handoffDuringReadKeepsTheSelectedBuffersLayout() { + class SwitchingBuffer extends WrappedBuffer { + Runnable handoff; + + SwitchingBuffer() { + super(ClientData.GameData.SIZE); + } + + @Override + byte getByte(int offset) { + if (handoff != null) handoff.run(); + return super.getByte(offset); + } + } + + SwitchingBuffer live = new SwitchingBuffer(); + PackedWrappedBuffer packed = new PackedWrappedBuffer(); + ClientData source = new ClientData(); + source.setBuffer(live); + source.gameData().setIsVisible(0, 0, true); + new FrameBuffer(new BWClientConfiguration.Builder().withAsyncFrameBufferCapacity(1).build()) + .copyBuffer(live, packed); + + ClientDataAsync data = new ClientDataAsync(live); + data.setBuffer(live); + live.handoff = () -> data.setBuffer(packed); + assertTrue(data.gameData().isVisible(0, 0)); + assertSame(packed, data.buffer); + + live.handoff = null; + assertTrue(data.gameData().isVisible(0, 0)); + data.setBuffer(live); + assertTrue(data.gameData().isVisible(0, 0)); + assertSame(live, data.buffer); + } + + @Test + public void frameBufferAllocatesReducedMemory() { + // Full GameData buffer is ~33 MB + assertEquals(33017048, ClientData.GameData.SIZE); + // Packed dynamic FrameBuffer size is ~4.08 MB + assertEquals(4085300, FrameBuffer.BUFFER_SIZE); + assertTrue(FrameBuffer.BUFFER_SIZE < 5 * 1024 * 1024); + } + + @Test + public void clientDataAsyncReadsStaticDataFromStaticBuffer() { + WrappedBuffer mainBuffer = new WrappedBuffer(ClientData.GameData.SIZE); + PackedWrappedBuffer dynamicBuffer = new PackedWrappedBuffer(); + + // Populate static data in the main buffer + ClientData mainData = new ClientData(); + mainData.setBuffer(mainBuffer); + + mainData.gameData().setGetGroundHeight(10, 20, 2); + mainData.gameData().setIsWalkable(100, 200, true); + mainData.gameData().setIsBuildable(15, 25, true); + mainData.gameData().setMapTileRegionId(5, 6, (short) 42); + mainData.gameData().setMapSplitTilesMiniTileMask(3, (short) 0xAAAA); + mainData.gameData().setMapSplitTilesRegion1(3, (short) 11); + mainData.gameData().setMapSplitTilesRegion2(3, (short) 22); + mainData.gameData().setRegionCount(7); + mainData.gameData().getRegions(2).setId(99); + mainData.gameData().getRegions(2).setCenter_x(320); + mainData.gameData().getRegions(2).setCenter_y(240); + mainData.gameData().getRegions(2).setIsAccessible(true); + mainData.gameData().getRegions(2).setIsHigherGround(true); + + // Configure ClientDataAsync with mainBuffer as static and dynamicBuffer as active + ClientDataAsync asyncData = new ClientDataAsync(mainBuffer); + asyncData.setBuffer(dynamicBuffer); + + assertEquals(2, asyncData.gameData().getGroundHeight(10, 20)); + assertTrue(asyncData.gameData().isWalkable(100, 200)); + assertFalse(asyncData.gameData().isWalkable(100, 201)); + assertTrue(asyncData.gameData().isBuildable(15, 25)); + assertEquals(42, asyncData.gameData().getMapTileRegionId(5, 6)); + assertEquals((short) 0xAAAA, asyncData.gameData().getMapSplitTilesMiniTileMask(3)); + assertEquals(11, asyncData.gameData().getMapSplitTilesRegion1(3)); + assertEquals(22, asyncData.gameData().getMapSplitTilesRegion2(3)); + assertEquals(7, asyncData.gameData().getRegionCount()); + assertEquals(99, asyncData.gameData().getRegions(2).getId()); + assertEquals(320, asyncData.gameData().getRegions(2).getCenter_x()); + assertEquals(240, asyncData.gameData().getRegions(2).getCenter_y()); + assertTrue(asyncData.gameData().getRegions(2).isAccessible()); + assertTrue(asyncData.gameData().getRegions(2).isHigherGround()); + } + + @Test + public void clientDataAsyncReadsDynamicDataWithPackedOffsets() { + WrappedBuffer mainBuffer = new WrappedBuffer(ClientData.GameData.SIZE); + PackedWrappedBuffer packedBuffer = new PackedWrappedBuffer(); + + // Setup mock source data in mainBuffer + ClientData mainData = new ClientData(); + mainData.setBuffer(mainBuffer); + + // Range 1 (units, frameCount) + mainData.gameData().setFrameCount(1234); + mainData.gameData().getUnits(0).setHitPoints(40); + mainData.gameData().getUnits(0).setPositionX(640); + mainData.gameData().getUnits(0).setPositionY(480); + + // Range 2 (dynamic tiles: visible, explored, creep, occupied) + mainData.gameData().setIsVisible(30, 40, true); + mainData.gameData().setIsExplored(31, 41, true); + mainData.gameData().setHasCreep(32, 42, true); + mainData.gameData().setIsOccupied(33, 43, true); + + // Range 3 (dynamic game state, events, eventStrings) + mainData.gameData().setStartLocationCount(4); + mainData.gameData().getStartLocations(1).setX(128); + mainData.gameData().getStartLocations(1).setY(256); + mainData.gameData().setIsInGame(true); + mainData.gameData().setSelf(0); + mainData.gameData().setEnemy(1); + mainData.gameData().setEventCount(2); + mainData.gameData().getEvents(0).setType(EventType.MatchStart); + mainData.gameData().getEvents(1).setType(EventType.UnitDiscover); + mainData.gameData().getEvents(1).setV1(5); + mainData.gameData().setEventStringCount(1); + mainData.gameData().setEventStrings(0, "Hello BWAPI"); + + // Use FrameBuffer.copyBuffer to pack dynamic data from mainBuffer into packedBuffer + BWClientConfiguration config = new BWClientConfiguration.Builder().withAsyncFrameBufferCapacity(1).build(); + FrameBuffer frameBuffer = new FrameBuffer(config); + frameBuffer.copyBuffer(mainBuffer, packedBuffer); + + // Verify ClientDataAsync reads packed dynamic data accurately + ClientDataAsync asyncData = new ClientDataAsync(mainBuffer); + asyncData.setBuffer(packedBuffer); + + // Range 1 + assertEquals(1234, asyncData.gameData().getFrameCount()); + assertEquals(40, asyncData.gameData().getUnits(0).getHitPoints()); + assertEquals(640, asyncData.gameData().getUnits(0).getPositionX()); + assertEquals(480, asyncData.gameData().getUnits(0).getPositionY()); + + // Range 2 + assertTrue(asyncData.gameData().isVisible(30, 40)); + assertFalse(asyncData.gameData().isVisible(30, 41)); + assertTrue(asyncData.gameData().isExplored(31, 41)); + assertTrue(asyncData.gameData().getHasCreep(32, 42)); + assertTrue(asyncData.gameData().isOccupied(33, 43)); + + // Range 3 + assertEquals(4, asyncData.gameData().getStartLocationCount()); + assertEquals(128, asyncData.gameData().getStartLocations(1).getX()); + assertEquals(256, asyncData.gameData().getStartLocations(1).getY()); + assertTrue(asyncData.gameData().isInGame()); + assertEquals(0, asyncData.gameData().getSelf()); + assertEquals(1, asyncData.gameData().getEnemy()); + assertEquals(2, asyncData.gameData().getEventCount()); + assertEquals(EventType.MatchStart, asyncData.gameData().getEvents(0).getType()); + assertEquals(EventType.UnitDiscover, asyncData.gameData().getEvents(1).getType()); + assertEquals(5, asyncData.gameData().getEvents(1).getV1()); + assertEquals(1, asyncData.gameData().getEventStringCount()); + assertEquals("Hello BWAPI", asyncData.gameData().getEventStrings(0)); + } + + @Test + public void clientDataAsyncWorksWithFullUnpackedBuffer() { + // Tests asyncUnsafe mode where bot is placed directly on the full 33MB liveData buffer + WrappedBuffer fullBuffer = new WrappedBuffer(ClientData.GameData.SIZE); + + ClientData syncData = new ClientData(); + syncData.setBuffer(fullBuffer); + syncData.gameData().setIsVisible(10, 20, true); + syncData.gameData().setIsInGame(true); + + ClientDataAsync asyncData = new ClientDataAsync(fullBuffer); + asyncData.setBuffer(fullBuffer); // Unpacked full buffer + + assertTrue(asyncData.gameData().isVisible(10, 20)); + assertTrue(asyncData.gameData().isInGame()); + } + + @Test + public void copyBufferTracksMaxUnitsAndOnlyCopiesActiveUnitsAndEvents() { + WrappedBuffer mainBuffer = new WrappedBuffer(ClientData.GameData.SIZE); + PackedWrappedBuffer packedBuffer = new PackedWrappedBuffer(); + + ClientData mainData = new ClientData(); + mainData.setBuffer(mainBuffer); + + // Set initialUnitCount = 3 and populate unit #2 and unit #50 (beyond initialUnitCount) + mainData.gameData().setInitialUnitCount(3); + mainData.gameData().getUnits(2).setHitPoints(100); + mainData.gameData().getUnits(50).setHitPoints(250); + + // Populate event #0 (active) and event #5 (beyond eventCount = 1) + mainData.gameData().setEventCount(1); + mainData.gameData().getEvents(0).setType(EventType.MatchFrame); + mainData.gameData().getEvents(5).setType(EventType.MatchEnd); + + FrameBuffer frameBuffer = new FrameBuffer( + new BWClientConfiguration.Builder().withAsyncFrameBufferCapacity(1).build()); + frameBuffer.copyBuffer(mainBuffer, packedBuffer); + + ClientDataAsync asyncData = new ClientDataAsync(mainBuffer); + asyncData.setBuffer(packedBuffer); + + assertEquals(3, frameBuffer.getMaxUnitCount()); + assertEquals(100, asyncData.gameData().getUnits(2).getHitPoints()); + // Unit #50 and Event #5 were beyond active counts, so they were skipped + assertEquals(0, asyncData.gameData().getUnits(50).getHitPoints()); + assertEquals(EventType.MatchFrame, asyncData.gameData().getEvents(0).getType()); + assertEquals(EventType.MatchStart, asyncData.gameData().getEvents(5).getType()); + + // Now fire UnitDiscover for unit #50 on the next frame + mainData.gameData().setEventCount(1); + mainData.gameData().getEvents(0).setType(EventType.UnitDiscover); + mainData.gameData().getEvents(0).setV1(50); + frameBuffer.copyBuffer(mainBuffer, packedBuffer); + + assertEquals(51, frameBuffer.getMaxUnitCount()); + assertEquals(250, asyncData.gameData().getUnits(50).getHitPoints()); + } +} diff --git a/src/test/java/bwapi/SynchronizationEnvironment.java b/src/test/java/bwapi/SynchronizationEnvironment.java index fc4ecc1..04e934a 100644 --- a/src/test/java/bwapi/SynchronizationEnvironment.java +++ b/src/test/java/bwapi/SynchronizationEnvironment.java @@ -22,6 +22,10 @@ class SynchronizationEnvironment { private final Map onFrames; SynchronizationEnvironment(final BWClientConfiguration bwClientConfiguration) { + this(bwClientConfiguration, GameBuilder.binToBufferUnchecked(GameBuilder.DEFAULT_BUFFER_PATH)); + } + + SynchronizationEnvironment(final BWClientConfiguration bwClientConfiguration, final WrappedBuffer newGameState) { BWEventListener listener = mock(BWEventListener.class); configuration = bwClientConfiguration; @@ -32,7 +36,6 @@ class SynchronizationEnvironment { bwapiDelayMs = 0; onFrames = new HashMap<>(); - WrappedBuffer newGameState = GameBuilder.binToBufferUnchecked(GameBuilder.DEFAULT_BUFFER_PATH); when(client.mapFile()).thenReturn(newGameState); when(client.liveClientData()).thenReturn(new ClientData()); client.liveClientData().setBuffer(client.mapFile()); @@ -88,7 +91,7 @@ void runGame(int onEndFrame) { if (configuration.getAsync()) { final long MEGABYTE = 1024 * 1024; long memoryFree = Runtime.getRuntime().freeMemory() / MEGABYTE; - long memoryRequired = configuration.getAsyncFrameBufferCapacity() * ClientData.GameData.SIZE / MEGABYTE; + long memoryRequired = (long) configuration.getAsyncFrameBufferCapacity() * FrameBuffer.BUFFER_SIZE / MEGABYTE; assertTrue( "Unit test needs to be run with sufficient memory to allocate frame buffer. Has " + memoryFree diff --git a/src/test/java/bwapi/SynchronizationTest.java b/src/test/java/bwapi/SynchronizationTest.java index 56536de..ee420f6 100644 --- a/src/test/java/bwapi/SynchronizationTest.java +++ b/src/test/java/bwapi/SynchronizationTest.java @@ -2,6 +2,9 @@ import org.junit.Test; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.IntStream; import static org.junit.Assert.*; @@ -30,6 +33,68 @@ private void assertWithin(String message, double expected, double actual, double measureApproximateEquality(expected, actual, margin)); } + @Test + public void asyncUnsafe_StartsBotBeforeCopyCompletes() { + CountDownLatch botStarted = new CountDownLatch(1); + AtomicBoolean overlapped = new AtomicBoolean(); + WrappedBuffer live = new WrappedBuffer(ClientData.GameData.SIZE) { + private boolean firstCopy = true; + + @Override long getAddress() { + if (firstCopy) { + firstCopy = false; + try { + // Hold the first copy until the bot starts reading the live frame. + overlapped.set(botStarted.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + return super.getAddress(); + } + }; + WrappedBuffer fixture = GameBuilder.binToBufferUnchecked(GameBuilder.DEFAULT_BUFFER_PATH); + fixture.getBuffer().rewind(); + live.getBuffer().put(fixture.getBuffer()); + BWClientConfiguration config = new BWClientConfiguration.Builder() + .withAsync(true) + .withAsyncUnsafe(true) + .withAsyncFrameBufferCapacity(2) + .build(); + SynchronizationEnvironment environment = new SynchronizationEnvironment(config, live); + environment.onFrame(0, botStarted::countDown); + environment.runGame(6); + assertTrue("Unsafe async must overlap bot execution with the frame copy", overlapped.get()); + } + + @Test + public void asyncUnsafe_KeepsLiveDataOnTimeAndSwapsToFrameBufferOnTimeout() { + WrappedBuffer live = GameBuilder.binToBufferUnchecked(GameBuilder.DEFAULT_BUFFER_PATH); + BWClientConfiguration config = new BWClientConfiguration.Builder() + .withAsync(true) + .withAsyncUnsafe(true) + .withUnlimitedFrameZero(false) + .withMaxFrameDurationMs(30) + .withAsyncFrameBufferCapacity(3) + .build(); + SynchronizationEnvironment environment = new SynchronizationEnvironment(config, live); + + environment.onFrame(1, () -> { + sleepUnchecked(5); + assertSame("On-time frame should stay on liveData without mid-frame swap", + live, environment.bwClient.getGame().botClientData().buffer); + }); + environment.onFrame(2, () -> { + assertSame("Start of frame should read liveData", + live, environment.bwClient.getGame().botClientData().buffer); + sleepUnchecked(80); + assertTrue("Timed-out frame should swap bot onto PackedWrappedBuffer", + environment.bwClient.getGame().botClientData().buffer instanceof PackedWrappedBuffer); + }); + + environment.runGame(5); + } + @Test public void sync_IfException_ThrowException() { BWClientConfiguration config = new BWClientConfiguration.Builder() @@ -323,4 +388,46 @@ public void async_DisablesLatencyCompensation() { environmentAsync.onFrame(2, () -> { assertThrows(IllegalStateException.class, () -> environmentAsync.bwClient.getGame().setLatCom(true)); }); environmentAsync.runGame(3); } + + @Test + public void sideEffectQueue_DoesNotBlockEnqueueDuringFlush() throws Exception { + SideEffectQueue queue = new SideEffectQueue(); + CountDownLatch inFlush = new CountDownLatch(1); + CountDownLatch enqueueDone = new CountDownLatch(1); + AtomicBoolean secondApplied = new AtomicBoolean(false); + + SideEffect first = org.mockito.Mockito.mock(SideEffect.class); + org.mockito.Mockito.doAnswer(inv -> { + inFlush.countDown(); + assertTrue("enqueue should not be blocked while flushTo is applying side effects", + enqueueDone.await(5, TimeUnit.SECONDS)); + return null; + }).when(first).apply(null); + + SideEffect second = org.mockito.Mockito.mock(SideEffect.class); + org.mockito.Mockito.doAnswer(inv -> { + secondApplied.set(true); + return null; + }).when(second).apply(null); + + queue.enqueue(first); + + Thread enqueuer = new Thread(() -> { + try { + assertTrue(inFlush.await(5, TimeUnit.SECONDS)); + queue.enqueue(second); + enqueueDone.countDown(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + enqueuer.start(); + + queue.flushTo(null); + enqueuer.join(5000); + assertFalse("Second side effect should remain queued for the next flush", secondApplied.get()); + + queue.flushTo(null); + assertTrue("Second side effect should be applied on the next flush", secondApplied.get()); + } }