From 73c59e40de8605867bea27b6f2762faebc0ac795 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 19:47:11 +0200 Subject: [PATCH 1/4] Handle MPQ maps without HM3W headers --- .../languageserver/ProjectConfigBuilder.java | 29 ++++++++-- .../tests/WurstBuildConfigTests.java | 53 +++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java index c8af822ad..1752d26e6 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java @@ -358,13 +358,24 @@ private static void setVolatilePlayerConfig(WurstProjectBuildPlayer wplayer, W3I private static void applyMapHeader(WurstProjectConfigData projectConfig, File targetMap) throws IOException { boolean shouldWrite = false; - MapHeader mapHeader = MapHeader.ofFile(targetMap); - if (!projectConfig.buildMapData().players().isEmpty()) { - mapHeader.setMaxPlayersCount(projectConfig.buildMapData().players().size()); + WurstProjectBuildMapData buildMapData = projectConfig.buildMapData(); + if (buildMapData.players().isEmpty() && StringUtils.isBlank(buildMapData.name())) { + return; + } + + // A Warcraft III map may omit the optional 512-byte HM3W prefix and start + // directly with its MPQ archive. MapHeader.ofFile only reads the prefix, + // so use a new header in that case; writeToMapFile will insert it before + // the archive. + MapHeader mapHeader = startsWithMpqArchive(targetMap) + ? new MapHeader() + : MapHeader.ofFile(targetMap); + if (!buildMapData.players().isEmpty()) { + mapHeader.setMaxPlayersCount(buildMapData.players().size()); shouldWrite = true; } - if (StringUtils.isNotBlank(projectConfig.buildMapData().name())) { - mapHeader.setMapName(projectConfig.buildMapData().name()); + if (StringUtils.isNotBlank(buildMapData.name())) { + mapHeader.setMapName(buildMapData.name()); shouldWrite = true; } if (shouldWrite) { @@ -372,4 +383,12 @@ private static void applyMapHeader(WurstProjectConfigData projectConfig, File ta mapHeader.writeToMapFile(targetMap); } } + + private static boolean startsWithMpqArchive(File targetMap) throws IOException { + try (InputStream input = new FileInputStream(targetMap)) { + byte[] startToken = input.readNBytes(4); + return startToken.length == 4 + && new String(startToken, StandardCharsets.US_ASCII).startsWith("MPQ"); + } + } } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java index 17d8492af..59ffef046 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java @@ -1,11 +1,13 @@ package tests.wurstscript.tests; import org.wurstscript.projectconfig.WurstProjectConfigData; +import org.wurstscript.projectconfig.WurstProjectBuildMapData; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstio.languageserver.ProjectConfigBuilder; import de.peeeq.wurstio.languageserver.WurstBuildConfig; import de.peeeq.wurstio.languageserver.WurstCommands; import de.peeeq.wurstio.utils.W3InstallationData; +import net.moonlightflower.wc3libs.bin.app.MapHeader; import net.moonlightflower.wc3libs.port.GameVersion; import org.testng.annotations.Test; @@ -223,6 +225,57 @@ public void configInjectionPrefersPinnedPatchOverDetectedInstallVersion() throws ); } + @Test + public void mapHeaderConfigAcceptsAnArchiveWithoutAnHm3wPrefix() throws Exception { + Path mapWithoutHeader = Files.createTempFile("wurst-map-without-header", ".w3x"); + byte[] original = new byte[1024]; + original[0] = 'M'; + original[1] = 'P'; + original[2] = 'Q'; + original[3] = 0x1a; + Files.write(mapWithoutHeader, original); + + Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( + "applyMapHeader", WurstProjectConfigData.class, File.class + ); + applyMapHeader.setAccessible(true); + + applyMapHeader.invoke( + null, + new WurstProjectConfigData( + "Test", + List.of(), + new WurstProjectBuildMapData("Configured map", null, null, null, null, List.of(), List.of()), + null, + null + ), + mapWithoutHeader.toFile() + ); + + assertEquals(Files.readAllBytes(mapWithoutHeader)[0], (byte) 'H'); + assertEquals(MapHeader.ofFile(mapWithoutHeader.toFile()).getMaxPlayersCount(), 0); + assertEquals(Files.size(mapWithoutHeader), original.length + 512); + } + + @Test + public void mapHeaderConfigDoesNotReadAnArchiveWhenNothingNeedsChanging() throws Exception { + Path mapWithoutHeader = Files.createTempFile("wurst-map-without-header-noop", ".w3x"); + byte[] original = new byte[1024]; + original[0] = 'M'; + original[1] = 'P'; + original[2] = 'Q'; + original[3] = 0x1a; + Files.write(mapWithoutHeader, original); + + Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( + "applyMapHeader", WurstProjectConfigData.class, File.class + ); + applyMapHeader.setAccessible(true); + applyMapHeader.invoke(null, WurstProjectConfigData.empty(), mapWithoutHeader.toFile()); + + assertEquals(Files.readAllBytes(mapWithoutHeader), original); + } + private static String calculateProjectConfigHash(File buildDir) throws Exception { Method method = ProjectConfigBuilder.class.getDeclaredMethod( "calculateProjectConfigHash", From d54d514371278debc884b05d83f34d4524c8e22e Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 19:55:25 +0200 Subject: [PATCH 2/4] Preserve W3I player count in synthesized headers --- .../wurstio/languageserver/ProjectConfigBuilder.java | 10 +++++++--- .../wurstscript/tests/WurstBuildConfigTests.java | 11 ++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java index 1752d26e6..b19b39471 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java @@ -107,7 +107,7 @@ public static MapRequest.CompilationResult apply(WurstProjectConfigData projectC w3I.write(result.w3i); // Apply map header (this is cheap, so we always do it) - applyMapHeader(projectConfig, targetMap); + applyMapHeader(projectConfig, targetMap, w3I.getPlayers().size()); // Update the manifest with new config hash (must open writable to insert) try (MpqEditor mpq = MpqEditorFactory.getEditor(Optional.of(targetMap), false)) { @@ -356,7 +356,8 @@ private static void setVolatilePlayerConfig(WurstProjectBuildPlayer wplayer, W3I } } - private static void applyMapHeader(WurstProjectConfigData projectConfig, File targetMap) throws IOException { + private static void applyMapHeader(WurstProjectConfigData projectConfig, File targetMap, + int existingPlayerCount) throws IOException { boolean shouldWrite = false; WurstProjectBuildMapData buildMapData = projectConfig.buildMapData(); if (buildMapData.players().isEmpty() && StringUtils.isBlank(buildMapData.name())) { @@ -367,12 +368,15 @@ private static void applyMapHeader(WurstProjectConfigData projectConfig, File ta // directly with its MPQ archive. MapHeader.ofFile only reads the prefix, // so use a new header in that case; writeToMapFile will insert it before // the archive. - MapHeader mapHeader = startsWithMpqArchive(targetMap) + boolean hasNoMapHeader = startsWithMpqArchive(targetMap); + MapHeader mapHeader = hasNoMapHeader ? new MapHeader() : MapHeader.ofFile(targetMap); if (!buildMapData.players().isEmpty()) { mapHeader.setMaxPlayersCount(buildMapData.players().size()); shouldWrite = true; + } else if (hasNoMapHeader) { + mapHeader.setMaxPlayersCount(existingPlayerCount); } if (StringUtils.isNotBlank(buildMapData.name())) { mapHeader.setMapName(buildMapData.name()); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java index 59ffef046..111955648 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java @@ -236,7 +236,7 @@ public void mapHeaderConfigAcceptsAnArchiveWithoutAnHm3wPrefix() throws Exceptio Files.write(mapWithoutHeader, original); Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( - "applyMapHeader", WurstProjectConfigData.class, File.class + "applyMapHeader", WurstProjectConfigData.class, File.class, int.class ); applyMapHeader.setAccessible(true); @@ -249,11 +249,12 @@ public void mapHeaderConfigAcceptsAnArchiveWithoutAnHm3wPrefix() throws Exceptio null, null ), - mapWithoutHeader.toFile() + mapWithoutHeader.toFile(), + 3 ); assertEquals(Files.readAllBytes(mapWithoutHeader)[0], (byte) 'H'); - assertEquals(MapHeader.ofFile(mapWithoutHeader.toFile()).getMaxPlayersCount(), 0); + assertEquals(MapHeader.ofFile(mapWithoutHeader.toFile()).getMaxPlayersCount(), 3); assertEquals(Files.size(mapWithoutHeader), original.length + 512); } @@ -268,10 +269,10 @@ public void mapHeaderConfigDoesNotReadAnArchiveWhenNothingNeedsChanging() throws Files.write(mapWithoutHeader, original); Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( - "applyMapHeader", WurstProjectConfigData.class, File.class + "applyMapHeader", WurstProjectConfigData.class, File.class, int.class ); applyMapHeader.setAccessible(true); - applyMapHeader.invoke(null, WurstProjectConfigData.empty(), mapWithoutHeader.toFile()); + applyMapHeader.invoke(null, WurstProjectConfigData.empty(), mapWithoutHeader.toFile(), 0); assertEquals(Files.readAllBytes(mapWithoutHeader), original); } From 659e9091baf828242a1699a952f82f9cf5ae085c Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 20:03:57 +0200 Subject: [PATCH 3/4] Preserve W3I map name in synthesized headers --- .../languageserver/ProjectConfigBuilder.java | 7 ++- .../tests/WurstBuildConfigTests.java | 49 +++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java index b19b39471..872408e6e 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java @@ -107,7 +107,7 @@ public static MapRequest.CompilationResult apply(WurstProjectConfigData projectC w3I.write(result.w3i); // Apply map header (this is cheap, so we always do it) - applyMapHeader(projectConfig, targetMap, w3I.getPlayers().size()); + applyMapHeader(projectConfig, targetMap, w3I.getPlayers().size(), w3I.getMapName()); // Update the manifest with new config hash (must open writable to insert) try (MpqEditor mpq = MpqEditorFactory.getEditor(Optional.of(targetMap), false)) { @@ -357,7 +357,7 @@ private static void setVolatilePlayerConfig(WurstProjectBuildPlayer wplayer, W3I } private static void applyMapHeader(WurstProjectConfigData projectConfig, File targetMap, - int existingPlayerCount) throws IOException { + int existingPlayerCount, String existingMapName) throws IOException { boolean shouldWrite = false; WurstProjectBuildMapData buildMapData = projectConfig.buildMapData(); if (buildMapData.players().isEmpty() && StringUtils.isBlank(buildMapData.name())) { @@ -378,6 +378,9 @@ private static void applyMapHeader(WurstProjectConfigData projectConfig, File ta } else if (hasNoMapHeader) { mapHeader.setMaxPlayersCount(existingPlayerCount); } + if (hasNoMapHeader && StringUtils.isBlank(buildMapData.name())) { + mapHeader.setMapName(existingMapName); + } if (StringUtils.isNotBlank(buildMapData.name())) { mapHeader.setMapName(buildMapData.name()); shouldWrite = true; diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java index 111955648..eae4f35aa 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java @@ -2,6 +2,7 @@ import org.wurstscript.projectconfig.WurstProjectConfigData; import org.wurstscript.projectconfig.WurstProjectBuildMapData; +import org.wurstscript.projectconfig.WurstProjectBuildPlayer; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstio.languageserver.ProjectConfigBuilder; import de.peeeq.wurstio.languageserver.WurstBuildConfig; @@ -13,6 +14,7 @@ import java.io.File; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -236,7 +238,7 @@ public void mapHeaderConfigAcceptsAnArchiveWithoutAnHm3wPrefix() throws Exceptio Files.write(mapWithoutHeader, original); Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( - "applyMapHeader", WurstProjectConfigData.class, File.class, int.class + "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class ); applyMapHeader.setAccessible(true); @@ -250,7 +252,8 @@ public void mapHeaderConfigAcceptsAnArchiveWithoutAnHm3wPrefix() throws Exceptio null ), mapWithoutHeader.toFile(), - 3 + 3, + "Existing map" ); assertEquals(Files.readAllBytes(mapWithoutHeader)[0], (byte) 'H'); @@ -269,14 +272,52 @@ public void mapHeaderConfigDoesNotReadAnArchiveWhenNothingNeedsChanging() throws Files.write(mapWithoutHeader, original); Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( - "applyMapHeader", WurstProjectConfigData.class, File.class, int.class + "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class ); applyMapHeader.setAccessible(true); - applyMapHeader.invoke(null, WurstProjectConfigData.empty(), mapWithoutHeader.toFile(), 0); + applyMapHeader.invoke(null, WurstProjectConfigData.empty(), mapWithoutHeader.toFile(), 0, null); assertEquals(Files.readAllBytes(mapWithoutHeader), original); } + @Test + public void mapHeaderConfigPreservesExistingMapNameWhenOnlyPlayersChange() throws Exception { + Path mapWithoutHeader = Files.createTempFile("wurst-map-without-header-name", ".w3x"); + byte[] original = new byte[1024]; + original[0] = 'M'; + original[1] = 'P'; + original[2] = 'Q'; + original[3] = 0x1a; + Files.write(mapWithoutHeader, original); + + Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( + "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class + ); + applyMapHeader.setAccessible(true); + + applyMapHeader.invoke( + null, + new WurstProjectConfigData( + "Test", + List.of(), + new WurstProjectBuildMapData( + "", null, null, null, null, + List.of(new WurstProjectBuildPlayer(0, null, null, null, null)), + List.of() + ), + null, + null + ), + mapWithoutHeader.toFile(), + 4, + "Existing map" + ); + + byte[] result = Files.readAllBytes(mapWithoutHeader); + assertEquals(MapHeader.ofFile(mapWithoutHeader.toFile()).getMaxPlayersCount(), 1); + assertTrue(new String(result, StandardCharsets.UTF_8).contains("Existing map")); + } + private static String calculateProjectConfigHash(File buildDir) throws Exception { Method method = ProjectConfigBuilder.class.getDeclaredMethod( "calculateProjectConfigHash", From 7c1042339cb6b2c0f0b40d309bb8dc8ad7605e12 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 20:11:46 +0200 Subject: [PATCH 4/4] Preserve W3I flags in synthesized headers --- .../languageserver/ProjectConfigBuilder.java | 8 ++++++-- .../wurstscript/tests/WurstBuildConfigTests.java | 15 +++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java index 872408e6e..ebc76a419 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java @@ -107,7 +107,7 @@ public static MapRequest.CompilationResult apply(WurstProjectConfigData projectC w3I.write(result.w3i); // Apply map header (this is cheap, so we always do it) - applyMapHeader(projectConfig, targetMap, w3I.getPlayers().size(), w3I.getMapName()); + applyMapHeader(projectConfig, targetMap, w3I.getPlayers().size(), w3I.getMapName(), w3I.getFlags().toInt()); // Update the manifest with new config hash (must open writable to insert) try (MpqEditor mpq = MpqEditorFactory.getEditor(Optional.of(targetMap), false)) { @@ -357,7 +357,8 @@ private static void setVolatilePlayerConfig(WurstProjectBuildPlayer wplayer, W3I } private static void applyMapHeader(WurstProjectConfigData projectConfig, File targetMap, - int existingPlayerCount, String existingMapName) throws IOException { + int existingPlayerCount, String existingMapName, + int existingMapFlags) throws IOException { boolean shouldWrite = false; WurstProjectBuildMapData buildMapData = projectConfig.buildMapData(); if (buildMapData.players().isEmpty() && StringUtils.isBlank(buildMapData.name())) { @@ -381,6 +382,9 @@ private static void applyMapHeader(WurstProjectConfigData projectConfig, File ta if (hasNoMapHeader && StringUtils.isBlank(buildMapData.name())) { mapHeader.setMapName(existingMapName); } + if (hasNoMapHeader) { + mapHeader.setFlags(existingMapFlags); + } if (StringUtils.isNotBlank(buildMapData.name())) { mapHeader.setMapName(buildMapData.name()); shouldWrite = true; diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java index eae4f35aa..f78d35d14 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java @@ -238,7 +238,7 @@ public void mapHeaderConfigAcceptsAnArchiveWithoutAnHm3wPrefix() throws Exceptio Files.write(mapWithoutHeader, original); Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( - "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class + "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class, int.class ); applyMapHeader.setAccessible(true); @@ -253,7 +253,8 @@ public void mapHeaderConfigAcceptsAnArchiveWithoutAnHm3wPrefix() throws Exceptio ), mapWithoutHeader.toFile(), 3, - "Existing map" + "Existing map", + 0x1234 ); assertEquals(Files.readAllBytes(mapWithoutHeader)[0], (byte) 'H'); @@ -272,10 +273,10 @@ public void mapHeaderConfigDoesNotReadAnArchiveWhenNothingNeedsChanging() throws Files.write(mapWithoutHeader, original); Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( - "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class + "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class, int.class ); applyMapHeader.setAccessible(true); - applyMapHeader.invoke(null, WurstProjectConfigData.empty(), mapWithoutHeader.toFile(), 0, null); + applyMapHeader.invoke(null, WurstProjectConfigData.empty(), mapWithoutHeader.toFile(), 0, null, 0); assertEquals(Files.readAllBytes(mapWithoutHeader), original); } @@ -291,7 +292,7 @@ public void mapHeaderConfigPreservesExistingMapNameWhenOnlyPlayersChange() throw Files.write(mapWithoutHeader, original); Method applyMapHeader = ProjectConfigBuilder.class.getDeclaredMethod( - "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class + "applyMapHeader", WurstProjectConfigData.class, File.class, int.class, String.class, int.class ); applyMapHeader.setAccessible(true); @@ -310,11 +311,13 @@ public void mapHeaderConfigPreservesExistingMapNameWhenOnlyPlayersChange() throw ), mapWithoutHeader.toFile(), 4, - "Existing map" + "Existing map", + 0x1234 ); byte[] result = Files.readAllBytes(mapWithoutHeader); assertEquals(MapHeader.ofFile(mapWithoutHeader.toFile()).getMaxPlayersCount(), 1); + assertEquals(MapHeader.ofFile(mapWithoutHeader.toFile()).getFlags(), 0x1234); assertTrue(new String(result, StandardCharsets.UTF_8).contains("Existing map")); }