Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/maven.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,25 @@ jobs:
run: mvn -B package --file pom.xml
- name: Test with Maven
run: mvn test

windows-java8-x86:
name: Windows Java 8 x86
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- name: Set up the primary bot runtime
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '8'
architecture: 'x86'
- name: Verify 32-bit Java 8
shell: pwsh
run: |
$settings = (& java -XshowSettings:properties -version 2>&1 | Out-String)
if ($settings -notmatch 'sun.arch.data.model\s*=\s*32' -or $settings -notmatch 'java.specification.version\s*=\s*1\.8') {
throw 'Expected a 32-bit Java 8 JVM'
}
java -version
- name: Build and test with Maven
run: mvn -B package --file pom.xml
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ or if you already have maven installed

`mvn package`

The build also produces `target/jbwapi-2.2.0-distribution.zip`. Keep its DLLs beside
your bot's JAR to avoid unpacking them at startup, so the bot can connect sooner.
The DLLs must match your bot's JNA version. If they are omitted, JNA extracts its
library normally at startup.

## Example

A simple `Hello World` bot is as easy as:
Expand Down
25 changes: 25 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>distribution</id>
<phase>package</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<target>
<zip destfile="${project.build.directory}/${project.build.finalName}-distribution.zip">
<zipfileset dir="${project.build.directory}" includes="${project.build.finalName}-jar-with-dependencies.jar" fullpath="${project.build.finalName}-jar-with-dependencies.jar"/>
<mappedresources>
<zipfileset src="${project.build.directory}/${project.build.finalName}-jar-with-dependencies.jar" includes="com/sun/jna/win32-*/jnidispatch.dll"/>
<regexpmapper from="com/sun/jna/(win32-[^/]+)/jnidispatch\.dll" to="jnidispatch-\1.dll"/>
</mappedresources>
</zip>
</target>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-site-plugin</artifactId>
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/bwapi/BWClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,18 @@ public void startGame(boolean autoContinue) {
*/
public void startGame(BWClientConfiguration gameConfiguration) {
this.configuration = gameConfiguration;
this.performanceMetrics = new PerformanceMetrics(configuration);
botWrapper = configuration.getAsync()
? new BotWrapperAsync(configuration, eventListener)
: new BotWrapper(configuration, eventListener);

if (client == null) {
client = new Client(this);
}
client.reconnect();

// Allocate game bookkeeping and async frame buffers only after the BWAPI handshake.
this.performanceMetrics = new PerformanceMetrics(configuration);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My naive assumption is that change is actually the most important for the startup boost (e.g. the Client starting to listen ASAP). Do you have individual timing on how much each of the 3 changes actually brings individually?

@dgant dgant Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Using pre-extracted JNA DLL: -45%
  • Defer buffer allocation: -26%
  • Narrow kernel32 bindings: -7%

The deferred buffer allocation is certainly the easiest win though :)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok interesting, thanks for the numbers. I would prefer putting the "Using pre-extracted JNA DLL" (most complicated) in another PR which I can look at in more detail another time, the deferral and narrow bindings look like free wins, those LGTM for this CL, thanks a bunch!

botWrapper = configuration.getAsync()
? new BotWrapperAsync(configuration, eventListener)
: new BotWrapper(configuration, eventListener);

do {
ClientData.GameData liveGameData = client.liveClientData().gameData();
while (!liveGameData.isInGame()) {
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/bwapi/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ class Client {

Client(BWClient bwClient) {
this.bwClient = bwClient;
boolean windowsOs = System.getProperty("os.name").toLowerCase().contains("win");
clientConnector = windowsOs ? new ClientConnectionW32() : new ClientConnectionPosix();
if (System.getProperty("os.name").toLowerCase().contains("win")) {
JnaNativeLoader.initialize();
clientConnector = new ClientConnectionW32();
} else {
clientConnector = new ClientConnectionPosix();
}
}

/**
Expand Down
20 changes: 15 additions & 5 deletions src/main/java/bwapi/ClientConnectionW32.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.platform.win32.Kernel32;
import com.sun.jna.platform.win32.WinNT.HANDLE;
import com.sun.jna.win32.StdCallLibrary;
import com.sun.jna.win32.W32APIOptions;

import java.io.FileNotFoundException;
Expand All @@ -15,10 +16,19 @@
class ClientConnectionW32 implements ClientConnection {
private static final int READ_WRITE = 0x1 | 0x2 | 0x4;

interface MappingKernel extends Kernel32 {
MappingKernel INSTANCE = Native.load(MappingKernel.class, W32APIOptions.DEFAULT_OPTIONS);
/**
* Opens the BWAPI server's game table and game-state shared-memory mappings.
* Binding only these two kernel32 functions avoids initializing JNA's full
* Kernel32 proxy before the client can discover a server.
Comment thread
JasperGeurtz marked this conversation as resolved.
Resolved
* Measured savings: about 28-31 ms on Windows/Java 8 with JNA 5.18.1
* in fresh-JVM startup tests; actual savings depend on the machine.
*/
interface MappingKernel extends StdCallLibrary {
MappingKernel INSTANCE = Native.load("kernel32", MappingKernel.class, W32APIOptions.DEFAULT_OPTIONS);

HANDLE OpenFileMapping(int desiredAccess, boolean inherit, String name);

Pointer MapViewOfFile(HANDLE mapping, int desiredAccess, int offsetHigh, int offsetLow, int bytesToMap);
}


Expand All @@ -38,7 +48,7 @@ public void disconnect() {

@Override
public WrappedBuffer getGameTable() {
final Pointer gameTableView = Kernel32.INSTANCE.MapViewOfFile(MappingKernel.INSTANCE
final Pointer gameTableView = MappingKernel.INSTANCE.MapViewOfFile(MappingKernel.INSTANCE
.OpenFileMapping(READ_WRITE, false, "Local\\bwapi_shared_memory_game_list"), READ_WRITE,
0, 0, GameTable.SIZE);
return new WrappedBuffer(gameTableView, GameTable.SIZE);
Expand All @@ -48,7 +58,7 @@ public WrappedBuffer getGameTable() {
public WrappedBuffer getSharedMemory(int serverProcID) {
String sharedMemoryName = "Local\\bwapi_shared_memory_" + serverProcID;
try {
final Pointer mapFileView = Kernel32.INSTANCE.MapViewOfFile(MappingKernel.INSTANCE
final Pointer mapFileView = MappingKernel.INSTANCE.MapViewOfFile(MappingKernel.INSTANCE
.OpenFileMapping(READ_WRITE, false, sharedMemoryName), READ_WRITE,
0, 0, ClientData.GameData.SIZE);
return new WrappedBuffer(mapFileView, ClientData.GameData.SIZE);
Expand Down
147 changes: 147 additions & 0 deletions src/main/java/bwapi/JnaNativeLoader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package bwapi;

import com.sun.jna.Native;
import com.sun.jna.Platform;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;

import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
import static java.nio.file.StandardOpenOption.READ;

/**
* Loads a matching Windows JNA DLL beside the bot's JAR before connecting to BWAPI.
* Shipping the DLL separately avoids extracting it to a temporary file on each launch.
* The file must match the JAR's embedded library for the running JVM's architecture.
*
* <p>Missing, stale, or unusable DLLs leave JNA's ordinary loading path available.
* Other platforms also use ordinary JNA loading. Explicit JNA loading settings
* and an already initialized JNA take precedence.
*/
final class JnaNativeLoader {
private static final String BOOT_PATH = "jna.boot.library.path";
private static final String BOOT_NAME = "jna.boot.library.name";

private JnaNativeLoader() {
}

/**
* Call before constructing a platform connector or otherwise initializing JNA.
*/
static synchronized void initialize() {
String path;
String bootName;
try {
if (!Platform.isWindows() || isConfigured()) {
return;
}
byte[] binary = readEmbeddedLibrary();
if (binary == null) {
return;
}
Path library = bundledLibrary(binary);
if (library == null || isConfigured()) {
return;
}
path = library.getParent().toAbsolutePath().toString();
bootName = "jnidispatch-" + Platform.RESOURCE_PREFIX;
} catch (IOException | RuntimeException unavailable) {
// An optional optimization must not prevent JNA's ordinary loading path.
return;
}
loadPreparedLibrary(path, bootName);
}

private static byte[] readEmbeddedLibrary() throws IOException {
String resource = "/com/sun/jna/" + Platform.RESOURCE_PREFIX + "/jnidispatch.dll";
// A class literal does not initialize Native; use its loader's exact binary.
try (InputStream input = Native.class.getResourceAsStream(resource);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
if (input == null) {
return null;
}
byte[] buffer = new byte[32768];
int size;
while ((size = input.read(buffer)) != -1) {
output.write(buffer, 0, size);
}
return output.toByteArray();
}
}

private static void loadPreparedLibrary(String path, String bootName) {
try {
try {
System.setProperty(BOOT_PATH, path);
System.setProperty(BOOT_NAME, bootName);
} catch (RuntimeException unavailable) {
return;
}
// JNA itself falls back to ordinary extraction if this binary cannot be loaded.
Class.forName(Native.class.getName(), true, Native.class.getClassLoader());
} catch (ClassNotFoundException impossible) {
throw new IllegalStateException("Cannot initialize JNA: its class loader could not find "
+ "com.sun.jna.Native, which is required to load the native library. Ensure JNA is included "
+ "in the bot's runtime classpath and is visible to its class loader.", impossible);
} finally {
// Do not leave a process-wide path pointing at this class loader's JNA version.
if (path.equals(System.getProperty(BOOT_PATH))) {
System.clearProperty(BOOT_PATH);
}
if (bootName.equals(System.getProperty(BOOT_NAME))) {
System.clearProperty(BOOT_NAME);
}
}
}

private static boolean isConfigured() {
if (Boolean.getBoolean("jna.loaded")) {
return true;
}
for (String setting : new String[] {BOOT_PATH, BOOT_NAME,
"jna.noclasspath", "jna.nosys", "jna.prefix", "jnidispatch.preserve"}) {
if (System.getProperty(setting) != null) {
return true;
}
}
return false;
}

private static Path bundledLibrary(byte[] binary) {
try {
// Resolve beside the JAR (or wrapped EXE), never relative to a launcher's working directory.
Path artifact = Paths.get(JnaNativeLoader.class.getProtectionDomain().getCodeSource().getLocation().toURI());
Path library = artifact.resolveSibling("jnidispatch-" + Platform.RESOURCE_PREFIX + ".dll");
// A replaced JAR must not accidentally load a DLL left over from another JNA version.
return matches(library, binary) ? library : null;
} catch (IOException | URISyntaxException | RuntimeException unavailable) {
return null;
}
}

private static boolean matches(Path file, byte[] expected) throws IOException {
if (!Files.exists(file, NOFOLLOW_LINKS)) {
return false;
}
try (SeekableByteChannel channel = Files.newByteChannel(file, READ, NOFOLLOW_LINKS)) {
if (channel.size() != expected.length) {
return false;
}
ByteBuffer bytes = ByteBuffer.allocate(expected.length);
while (bytes.hasRemaining()) {
if (channel.read(bytes) < 0) {
return false;
}
}
return Arrays.equals(expected, bytes.array());
}
}
}
49 changes: 49 additions & 0 deletions src/test/java/bwapi/BWClientStartupTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package bwapi;

import org.junit.Test;

import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;

import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;

/**
* Guards the order of startup work: attempt the BWAPI connection before creating
* performance metrics or allocating the large buffers used for asynchronous frames.
* Measures direct-buffer allocation at a mocked connection boundary, so the test
* needs neither a running server nor a machine-dependent elapsed-time threshold.
*/
public class BWClientStartupTest {
// Stops startGame at the mocked connection boundary, before a game loop can run.
private static class StopAfterConnectionAttempt extends RuntimeException {
}

@Test
public void attemptsConnectionBeforeAllocatingFrameBuffersOrMetrics() {
BWClient bwClient = new BWClient(new DefaultBWListener());
Client client = mock(Client.class);
bwClient.setClient(client);
BWClientConfiguration configuration = new BWClientConfiguration.Builder()
.withAsync(true)
.withAsyncFrameBufferCapacity(3)
.build();
BufferPoolMXBean directBuffers = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)
.stream()
.filter(pool -> "direct".equals(pool.getName()))
.findFirst()
.get();
long bytesBeforeStart = directBuffers.getMemoryUsed();
doAnswer(invocation -> {
assertTrue("Connection attempts must precede allocation of even one game-state buffer",
directBuffers.getMemoryUsed() - bytesBeforeStart < ClientData.GameData.SIZE);
assertNull(bwClient.getPerformanceMetrics());
throw new StopAfterConnectionAttempt();
}).when(client).reconnect();

assertThrows(StopAfterConnectionAttempt.class, () -> bwClient.startGame(configuration));
}
}
Loading
Loading