Skip to content

Commit 85b1d81

Browse files
committed
Accelerate BWAPI connection readiness
Defer frame buffers and metrics until after the BWAPI handshake, and use a focused Windows shared-memory mapping interface. Automatically load an architecture-named JNA DLL beside the bot JAR after verifying it against the embedded binary. Preserve explicit JNA settings and ordinary loading when no matching bundle is available, without a custom native cache. Package portable Windows DLLs beside the JAR and cover native loading, startup ordering, mapping behavior, and Windows Java 8 x86 in tests and CI.
1 parent 0507796 commit 85b1d81

10 files changed

Lines changed: 561 additions & 11 deletions

File tree

‎.github/workflows/maven.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,25 @@ jobs:
3030
run: mvn -B package --file pom.xml
3131
- name: Test with Maven
3232
run: mvn test
33+
34+
windows-java8-x86:
35+
name: Windows Java 8 x86
36+
runs-on: windows-latest
37+
steps:
38+
- uses: actions/checkout@v5
39+
- name: Set up the primary bot runtime
40+
uses: actions/setup-java@v5
41+
with:
42+
distribution: 'temurin'
43+
java-version: '8'
44+
architecture: 'x86'
45+
- name: Verify 32-bit Java 8
46+
shell: pwsh
47+
run: |
48+
$settings = (& java -XshowSettings:properties -version 2>&1 | Out-String)
49+
if ($settings -notmatch 'sun.arch.data.model\s*=\s*32' -or $settings -notmatch 'java.specification.version\s*=\s*1\.8') {
50+
throw 'Expected a 32-bit Java 8 JVM'
51+
}
52+
java -version
53+
- name: Build and test with Maven
54+
run: mvn -B package --file pom.xml

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ or if you already have maven installed
8484

8585
`mvn package`
8686

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

8994
A simple `Hello World` bot is as easy as:

‎pom.xml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,31 @@
4646
</execution>
4747
</executions>
4848
</plugin>
49+
<plugin>
50+
<groupId>org.apache.maven.plugins</groupId>
51+
<artifactId>maven-antrun-plugin</artifactId>
52+
<version>3.1.0</version>
53+
<executions>
54+
<execution>
55+
<id>distribution</id>
56+
<phase>package</phase>
57+
<goals>
58+
<goal>run</goal>
59+
</goals>
60+
<configuration>
61+
<target>
62+
<zip destfile="${project.build.directory}/${project.build.finalName}-distribution.zip">
63+
<zipfileset dir="${project.build.directory}" includes="${project.build.finalName}-jar-with-dependencies.jar" fullpath="${project.build.finalName}-jar-with-dependencies.jar"/>
64+
<mappedresources>
65+
<zipfileset src="${project.build.directory}/${project.build.finalName}-jar-with-dependencies.jar" includes="com/sun/jna/win32-*/jnidispatch.dll"/>
66+
<regexpmapper from="com/sun/jna/(win32-[^/]+)/jnidispatch\.dll" to="jnidispatch-\1.dll"/>
67+
</mappedresources>
68+
</zip>
69+
</target>
70+
</configuration>
71+
</execution>
72+
</executions>
73+
</plugin>
4974
<plugin>
5075
<groupId>org.apache.maven.plugins</groupId>
5176
<artifactId>maven-site-plugin</artifactId>

‎src/main/java/bwapi/BWClient.java‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,18 @@ public void startGame(boolean autoContinue) {
8787
*/
8888
public void startGame(BWClientConfiguration gameConfiguration) {
8989
this.configuration = gameConfiguration;
90-
this.performanceMetrics = new PerformanceMetrics(configuration);
91-
botWrapper = configuration.getAsync()
92-
? new BotWrapperAsync(configuration, eventListener)
93-
: new BotWrapper(configuration, eventListener);
9490

9591
if (client == null) {
9692
client = new Client(this);
9793
}
9894
client.reconnect();
9995

96+
// Allocate game bookkeeping and async frame buffers only after the BWAPI handshake.
97+
this.performanceMetrics = new PerformanceMetrics(configuration);
98+
botWrapper = configuration.getAsync()
99+
? new BotWrapperAsync(configuration, eventListener)
100+
: new BotWrapper(configuration, eventListener);
101+
100102
do {
101103
ClientData.GameData liveGameData = client.liveClientData().gameData();
102104
while (!liveGameData.isInGame()) {

‎src/main/java/bwapi/Client.java‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,12 @@ class Client {
3737

3838
Client(BWClient bwClient) {
3939
this.bwClient = bwClient;
40-
boolean windowsOs = System.getProperty("os.name").toLowerCase().contains("win");
41-
clientConnector = windowsOs ? new ClientConnectionW32() : new ClientConnectionPosix();
40+
if (System.getProperty("os.name").toLowerCase().contains("win")) {
41+
JnaNativeLoader.initialize();
42+
clientConnector = new ClientConnectionW32();
43+
} else {
44+
clientConnector = new ClientConnectionPosix();
45+
}
4246
}
4347

4448
/**

‎src/main/java/bwapi/ClientConnectionW32.java‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import com.sun.jna.Native;
44
import com.sun.jna.Pointer;
5-
import com.sun.jna.platform.win32.Kernel32;
5+
import com.sun.jna.platform.win32.WinNT.HANDLE;
6+
import com.sun.jna.win32.StdCallLibrary;
67
import com.sun.jna.win32.W32APIOptions;
78

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

18-
interface MappingKernel extends Kernel32 {
19-
MappingKernel INSTANCE = Native.load(MappingKernel.class, W32APIOptions.DEFAULT_OPTIONS);
19+
/**
20+
* Opens the BWAPI server's game table and game-state shared-memory mappings.
21+
* Binding only these two kernel32 functions avoids initializing JNA's full
22+
* Kernel32 proxy before the client can discover a server.
23+
* Measured savings: about 28-31 ms on Windows/Java 8 with JNA 5.18.1
24+
* in fresh-JVM startup tests; actual savings depend on the machine.
25+
*/
26+
interface MappingKernel extends StdCallLibrary {
27+
MappingKernel INSTANCE = Native.load("kernel32", MappingKernel.class, W32APIOptions.DEFAULT_OPTIONS);
2028

2129
HANDLE OpenFileMapping(int desiredAccess, boolean inherit, String name);
30+
31+
Pointer MapViewOfFile(HANDLE mapping, int desiredAccess, int offsetHigh, int offsetLow, int bytesToMap);
2232
}
2333

2434

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

3949
@Override
4050
public WrappedBuffer getGameTable() {
41-
final Pointer gameTableView = Kernel32.INSTANCE.MapViewOfFile(MappingKernel.INSTANCE
51+
final Pointer gameTableView = MappingKernel.INSTANCE.MapViewOfFile(MappingKernel.INSTANCE
4252
.OpenFileMapping(READ_WRITE, false, "Local\\bwapi_shared_memory_game_list"), READ_WRITE,
4353
0, 0, GameTable.SIZE);
4454
return new WrappedBuffer(gameTableView, GameTable.SIZE);
@@ -48,7 +58,7 @@ public WrappedBuffer getGameTable() {
4858
public WrappedBuffer getSharedMemory(int serverProcID) {
4959
String sharedMemoryName = "Local\\bwapi_shared_memory_" + serverProcID;
5060
try {
51-
final Pointer mapFileView = Kernel32.INSTANCE.MapViewOfFile(MappingKernel.INSTANCE
61+
final Pointer mapFileView = MappingKernel.INSTANCE.MapViewOfFile(MappingKernel.INSTANCE
5262
.OpenFileMapping(READ_WRITE, false, sharedMemoryName), READ_WRITE,
5363
0, 0, ClientData.GameData.SIZE);
5464
return new WrappedBuffer(mapFileView, ClientData.GameData.SIZE);
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package bwapi;
2+
3+
import com.sun.jna.Native;
4+
import com.sun.jna.Platform;
5+
6+
import java.io.ByteArrayOutputStream;
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.net.URISyntaxException;
10+
import java.nio.ByteBuffer;
11+
import java.nio.channels.SeekableByteChannel;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.nio.file.Paths;
15+
import java.util.Arrays;
16+
17+
import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
18+
import static java.nio.file.StandardOpenOption.READ;
19+
20+
/**
21+
* Loads a matching Windows JNA DLL beside the bot's JAR before connecting to BWAPI.
22+
* Shipping the DLL separately avoids extracting it to a temporary file on each launch.
23+
* The file must match the JAR's embedded library for the running JVM's architecture.
24+
*
25+
* <p>Missing, stale, or unusable DLLs leave JNA's ordinary loading path available.
26+
* Other platforms also use ordinary JNA loading. Explicit JNA loading settings
27+
* and an already initialized JNA take precedence.
28+
*/
29+
final class JnaNativeLoader {
30+
private static final String BOOT_PATH = "jna.boot.library.path";
31+
private static final String BOOT_NAME = "jna.boot.library.name";
32+
33+
private JnaNativeLoader() {
34+
}
35+
36+
/**
37+
* Call before constructing a platform connector or otherwise initializing JNA.
38+
*/
39+
static synchronized void initialize() {
40+
String path;
41+
String bootName;
42+
try {
43+
if (!Platform.isWindows() || isConfigured()) {
44+
return;
45+
}
46+
byte[] binary = readEmbeddedLibrary();
47+
if (binary == null) {
48+
return;
49+
}
50+
Path library = bundledLibrary(binary);
51+
if (library == null || isConfigured()) {
52+
return;
53+
}
54+
path = library.getParent().toAbsolutePath().toString();
55+
bootName = "jnidispatch-" + Platform.RESOURCE_PREFIX;
56+
} catch (IOException | RuntimeException unavailable) {
57+
// An optional optimization must not prevent JNA's ordinary loading path.
58+
return;
59+
}
60+
loadPreparedLibrary(path, bootName);
61+
}
62+
63+
private static byte[] readEmbeddedLibrary() throws IOException {
64+
String resource = "/com/sun/jna/" + Platform.RESOURCE_PREFIX + "/jnidispatch.dll";
65+
// A class literal does not initialize Native; use its loader's exact binary.
66+
try (InputStream input = Native.class.getResourceAsStream(resource);
67+
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
68+
if (input == null) {
69+
return null;
70+
}
71+
byte[] buffer = new byte[32768];
72+
int size;
73+
while ((size = input.read(buffer)) != -1) {
74+
output.write(buffer, 0, size);
75+
}
76+
return output.toByteArray();
77+
}
78+
}
79+
80+
private static void loadPreparedLibrary(String path, String bootName) {
81+
try {
82+
try {
83+
System.setProperty(BOOT_PATH, path);
84+
System.setProperty(BOOT_NAME, bootName);
85+
} catch (RuntimeException unavailable) {
86+
return;
87+
}
88+
// JNA itself falls back to ordinary extraction if this binary cannot be loaded.
89+
Class.forName(Native.class.getName(), true, Native.class.getClassLoader());
90+
} catch (ClassNotFoundException impossible) {
91+
throw new IllegalStateException("Cannot initialize JNA: its class loader could not find "
92+
+ "com.sun.jna.Native, which is required to load the native library. Ensure JNA is included "
93+
+ "in the bot's runtime classpath and is visible to its class loader.", impossible);
94+
} finally {
95+
// Do not leave a process-wide path pointing at this class loader's JNA version.
96+
if (path.equals(System.getProperty(BOOT_PATH))) {
97+
System.clearProperty(BOOT_PATH);
98+
}
99+
if (bootName.equals(System.getProperty(BOOT_NAME))) {
100+
System.clearProperty(BOOT_NAME);
101+
}
102+
}
103+
}
104+
105+
private static boolean isConfigured() {
106+
if (Boolean.getBoolean("jna.loaded")) {
107+
return true;
108+
}
109+
for (String setting : new String[] {BOOT_PATH, BOOT_NAME,
110+
"jna.noclasspath", "jna.nosys", "jna.prefix", "jnidispatch.preserve"}) {
111+
if (System.getProperty(setting) != null) {
112+
return true;
113+
}
114+
}
115+
return false;
116+
}
117+
118+
private static Path bundledLibrary(byte[] binary) {
119+
try {
120+
// Resolve beside the JAR (or wrapped EXE), never relative to a launcher's working directory.
121+
Path artifact = Paths.get(JnaNativeLoader.class.getProtectionDomain().getCodeSource().getLocation().toURI());
122+
Path library = artifact.resolveSibling("jnidispatch-" + Platform.RESOURCE_PREFIX + ".dll");
123+
// A replaced JAR must not accidentally load a DLL left over from another JNA version.
124+
return matches(library, binary) ? library : null;
125+
} catch (IOException | URISyntaxException | RuntimeException unavailable) {
126+
return null;
127+
}
128+
}
129+
130+
private static boolean matches(Path file, byte[] expected) throws IOException {
131+
if (!Files.exists(file, NOFOLLOW_LINKS)) {
132+
return false;
133+
}
134+
try (SeekableByteChannel channel = Files.newByteChannel(file, READ, NOFOLLOW_LINKS)) {
135+
if (channel.size() != expected.length) {
136+
return false;
137+
}
138+
ByteBuffer bytes = ByteBuffer.allocate(expected.length);
139+
while (bytes.hasRemaining()) {
140+
if (channel.read(bytes) < 0) {
141+
return false;
142+
}
143+
}
144+
return Arrays.equals(expected, bytes.array());
145+
}
146+
}
147+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package bwapi;
2+
3+
import org.junit.Test;
4+
5+
import java.lang.management.BufferPoolMXBean;
6+
import java.lang.management.ManagementFactory;
7+
8+
import static org.junit.Assert.assertNull;
9+
import static org.junit.Assert.assertThrows;
10+
import static org.junit.Assert.assertTrue;
11+
import static org.mockito.Mockito.doAnswer;
12+
import static org.mockito.Mockito.mock;
13+
14+
/**
15+
* Guards the order of startup work: attempt the BWAPI connection before creating
16+
* performance metrics or allocating the large buffers used for asynchronous frames.
17+
* Measures direct-buffer allocation at a mocked connection boundary, so the test
18+
* needs neither a running server nor a machine-dependent elapsed-time threshold.
19+
*/
20+
public class BWClientStartupTest {
21+
// Stops startGame at the mocked connection boundary, before a game loop can run.
22+
private static class StopAfterConnectionAttempt extends RuntimeException {
23+
}
24+
25+
@Test
26+
public void attemptsConnectionBeforeAllocatingFrameBuffersOrMetrics() {
27+
BWClient bwClient = new BWClient(new DefaultBWListener());
28+
Client client = mock(Client.class);
29+
bwClient.setClient(client);
30+
BWClientConfiguration configuration = new BWClientConfiguration.Builder()
31+
.withAsync(true)
32+
.withAsyncFrameBufferCapacity(3)
33+
.build();
34+
BufferPoolMXBean directBuffers = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)
35+
.stream()
36+
.filter(pool -> "direct".equals(pool.getName()))
37+
.findFirst()
38+
.get();
39+
long bytesBeforeStart = directBuffers.getMemoryUsed();
40+
doAnswer(invocation -> {
41+
assertTrue("Connection attempts must precede allocation of even one game-state buffer",
42+
directBuffers.getMemoryUsed() - bytesBeforeStart < ClientData.GameData.SIZE);
43+
assertNull(bwClient.getPerformanceMetrics());
44+
throw new StopAfterConnectionAttempt();
45+
}).when(client).reconnect();
46+
47+
assertThrows(StopAfterConnectionAttempt.class, () -> bwClient.startGame(configuration));
48+
}
49+
}

0 commit comments

Comments
 (0)