diff --git a/AGENTS.md b/AGENTS.md
index 76bdda9..b9f449a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -48,6 +48,8 @@ This repo builds the Grill CLI and project setup tooling. The generated map-proj
- Build/typecheck should not require parsing the installed Warcraft executable when `wc3Patch` is pinned.
- Run/launch is different: the selected WC3 executable controls launch arguments. If the client family and project patch target differ, warn and allow choosing another WC3 folder.
+- `grill patch` is a read-only exact-version check. `grill patch align` is the explicit mutation boundary: update `wc3Patch`, the official stdlib branch, and Grill-owned core JASS together, and preserve custom stdlib forks.
+- Exact Reforged patch detection comes from the active Warcraft III row in Blizzard's `.build.info`. If it is missing or cannot map to a supported target, alignment must leave the project unchanged.
- Keep compiler-facing patch behavior tested in the WurstScript repo as well; Grill and compiler can diverge if only one side is tested.
## Test Commands
diff --git a/README.md b/README.md
index 343d051..5c00e55 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,24 @@ The command exits with code `0` when dependencies are up to date and `1` when up
> grill outdated
```
+### Aligning with the installed Warcraft III patch
+
+Use `patch` to compare the project target with the exact installed Warcraft III version. This is read-only:
+
+```cmd
+> grill patch
+```
+
+When Grill reports a mismatch, migrate the project configuration, official stdlib branch, and cached core JASS together:
+
+```cmd
+> grill patch align
+```
+
+Grill reads the configured VS Code game path first and falls back to automatic detection. Pass `--wc3-path
` to select a different installation. Alignment creates `wurst.build.bak` and makes no changes if an exact supported client patch cannot be detected.
+
+`grill install` also keeps the official stdlib dependency pinned to the branch required by the project's existing `wc3Patch`, and only suggests migration when it detects a different client patch.
+
### Building the project
diff --git a/src/main/kotlin/config/WurstProjectConfig.kt b/src/main/kotlin/config/WurstProjectConfig.kt
index 3d7d88b..2142a06 100644
--- a/src/main/kotlin/config/WurstProjectConfig.kt
+++ b/src/main/kotlin/config/WurstProjectConfig.kt
@@ -22,7 +22,11 @@ import java.nio.file.StandardOpenOption
*/
object WurstProjectConfig {
- private val MAPPER = JsonMapper.builder().enable(JsonReadFeature.ALLOW_TRAILING_COMMA).build()
+ private val MAPPER = JsonMapper.builder()
+ .enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
+ .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
+ .enable(JsonReadFeature.ALLOW_YAML_COMMENTS)
+ .build()
private val schema by lazy { javaClass.classLoader.getResource("wbschema.json") }
private val log = KotlinLogging.logger {}
@@ -42,14 +46,16 @@ object WurstProjectConfig {
}
@Throws(IOException::class)
- fun loadProject(buildFile: Path): WurstProjectConfigData? {
+ fun loadProject(buildFile: Path, persistRecovery: Boolean = true): WurstProjectConfigData? {
Log.println("Loading project..")
if (Files.exists(buildFile) && buildFile.fileName.toString().equals(CONFIG_FILE_NAME, ignoreCase = true)) {
- val config = YamlHelper.loadProjectConfig(buildFile)
+ val config = YamlHelper.loadProjectConfig(buildFile, persistRecovery)
val projectRoot = buildFile.parent
if (config.projectName.isBlank()) {
val namedConfig = config.withProjectName(projectRoot?.fileName?.toString() ?: "unnamed")
- saveProjectConfig(projectRoot, namedConfig)
+ if (persistRecovery) {
+ saveProjectConfig(projectRoot, namedConfig)
+ }
Log.print("done\n")
return namedConfig
}
@@ -145,6 +151,22 @@ object WurstProjectConfig {
Files.write(projectRoot.resolve(CONFIG_FILE_NAME), projectYaml.toByteArray())
}
+ fun configuredGamePath(projectRoot: Path): Path? {
+ val settings = projectRoot.resolve(".vscode").resolve("settings.json")
+ if (!Files.isRegularFile(settings)) {
+ return null
+ }
+ return try {
+ MAPPER.readTree(Files.readString(settings))
+ ?.get("wurst.wc3path")
+ ?.asText()
+ ?.takeIf(String::isNotBlank)
+ ?.let(Paths::get)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
@Throws(IOException::class)
private fun setupVSCode(projectRoot: Path?, gamePath: Path?) {
diff --git a/src/main/kotlin/file/CLICommand.kt b/src/main/kotlin/file/CLICommand.kt
index 2067966..9b017b5 100644
--- a/src/main/kotlin/file/CLICommand.kt
+++ b/src/main/kotlin/file/CLICommand.kt
@@ -10,7 +10,8 @@ enum class CLICommand {
GENERATE,
TEST,
TYPECHECK,
- OUTDATED,
+ OUTDATED,
+ PATCH,
BUILD,
EXPORTOBJECTS,
SELF_UPDATE
diff --git a/src/main/kotlin/file/CoreJassProvider.kt b/src/main/kotlin/file/CoreJassProvider.kt
index 793717f..4a72bed 100644
--- a/src/main/kotlin/file/CoreJassProvider.kt
+++ b/src/main/kotlin/file/CoreJassProvider.kt
@@ -165,6 +165,21 @@ object CoreJassProvider {
return withPrefix
}
+ fun patchTargetForClientVersion(version: String?): String? {
+ val parts = version?.trim()?.split('.') ?: return null
+ if (parts.size < 2 || parts[0].toIntOrNull() == null || parts[1].toIntOrNull() == null) {
+ return null
+ }
+ val target = "v${parts[0].toInt()}.${parts[1].toInt()}"
+ return target.takeIf(PATCH_TO_JASS_HISTORY_FOLDER::containsKey)
+ }
+
+ fun patchLine(patch: String?): String? {
+ val normalized = normalizePatchInput(patch)
+ val version = Wc3PatchTarget.parse(normalized).orElse(null)?.gameVersion() ?: return null
+ return patchTargetForClientVersion(version)
+ }
+
fun isPre129Patch(input: String?): Boolean {
val patch = normalizePatchInput(input)
return Wc3PatchTarget.parse(patch)
@@ -179,6 +194,13 @@ object CoreJassProvider {
.orElse(false)
}
+ fun isV3OrLaterPatch(input: String?): Boolean {
+ val patch = normalizePatchInput(input)
+ val target = Wc3PatchTarget.parse(patch).orElse(null) ?: return false
+ return target.kind() == Wc3PatchTarget.Kind.REFORGED &&
+ compareVersionStrings(target.gameVersion(), "3.0") >= 0
+ }
+
fun ensureFiles(projectRoot: Path, wc3Patch: String?): List {
val buildFolder = projectRoot.resolve("_build")
Files.createDirectories(buildFolder)
@@ -209,6 +231,15 @@ object CoreJassProvider {
return materializedFiles.map { it.path }
}
+ fun managedFilesNeedRefresh(projectRoot: Path, wc3Patch: String?): Boolean {
+ val buildFolder = projectRoot.resolve("_build")
+ val previousPatch = readProvenance(buildFolder) ?: return false
+ val patch = resolveSupportedPatch(wc3Patch)
+ return previousPatch != patch ||
+ !isValidCoreJassFile(buildFolder.resolve("common.j")) ||
+ !isValidCoreJassFile(buildFolder.resolve("blizzard.j"))
+ }
+
fun fetchJassHistoryVersions(): List {
val versionListUrl = "$JASS_HISTORY_RAW/$JASS_HISTORY_REF/$VERSION_LIST_FILE"
return try {
diff --git a/src/main/kotlin/file/SetupApp.kt b/src/main/kotlin/file/SetupApp.kt
index 87fa2d5..2ffc434 100644
--- a/src/main/kotlin/file/SetupApp.kt
+++ b/src/main/kotlin/file/SetupApp.kt
@@ -7,6 +7,7 @@ import config.WurstProjectBuildMapData
import config.WurstProjectConfigData
import config.newProjectConfig
import config.withAddedDependency
+import config.withDependencies
import config.withRemovedDependency
import config.withWc3Patch
import global.InstallationManager
@@ -21,6 +22,7 @@ import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
+import java.nio.file.StandardCopyOption
import java.util.*
import java.util.concurrent.TimeUnit
import javax.swing.JOptionPane
@@ -136,7 +138,10 @@ object SetupApp {
val configFile = setup.projectRoot.resolve(CONFIG_FILE_NAME)
var configData: WurstProjectConfigData? = null
if (Files.exists(configFile)) {
- configData = WurstProjectConfig.loadProject(configFile)!!
+ configData = WurstProjectConfig.loadProject(
+ configFile,
+ persistRecovery = setup.command != CLICommand.PATCH
+ )!!
}
when {
@@ -155,6 +160,7 @@ object SetupApp {
| test [filter] Run unit tests, optionally filtered by package/function name
| typecheck Typecheck the project without building a map
| outdated Check whether project dependencies are up to date
+ | patch [align] Compare project/client patches; align config and stdlib on request
| build Build the project using the given map archive or folder
| exportobjects Export object editor data to Wurst source
|
@@ -173,12 +179,16 @@ object SetupApp {
| --with-agents / --no-agents Include AGENTS.md (default: no)
| --with-ci / --no-ci Include GitHub Actions workflow (default: no)
| --with-dep Add a curated dependency (repeatable; ids: ${CuratedDependencies.ids.joinToString(", ")})
+ |
+ |Patch options:
+ | --wc3-path Warcraft III install folder to inspect
""".trimMargin())
}
setup.command == CLICommand.INSTALL -> {
if (setup.commandArg.isBlank()) {
if (configData != null) {
configData = ensureProjectPatchRecorded(configData)
+ suggestPatchAlignment(configData)
handleUpdateProject(configData)
} else {
missingProject()
@@ -191,6 +201,7 @@ object SetupApp {
if (configData != null) {
configData = handleInstallDep(configData)
configData = ensureProjectPatchRecorded(configData)
+ suggestPatchAlignment(configData)
WurstProjectConfig.saveProjectConfig(setup.projectRoot, configData)
handleUpdateProject(configData)
} else {
@@ -261,6 +272,13 @@ object SetupApp {
}
checkProjectOutdated(configData)
}
+ setup.command == CLICommand.PATCH -> {
+ if (configData == null) {
+ missingProject()
+ } else {
+ handlePatchAlignment(configData)
+ }
+ }
setup.command == CLICommand.BUILD -> {
progress("🔨 Building project...")
val mapArg = if (setup.commandArg.isBlank()) {
@@ -741,10 +759,96 @@ object SetupApp {
return when {
CoreJassProvider.isPre124(wc3Patch) -> "https://github.com/wurstscript/wurstStdlib2:pre1.24"
CoreJassProvider.isPre129Patch(wc3Patch) -> "https://github.com/wurstscript/wurstStdlib2:pre1.29"
- else -> "https://github.com/wurstscript/wurstStdlib2"
+ CoreJassProvider.isV3OrLaterPatch(wc3Patch) -> "https://github.com/wurstscript/wurstStdlib2"
+ else -> "https://github.com/wurstscript/wurstStdlib2:v2.0"
}
}
+ internal fun alignedProjectConfig(configData: WurstProjectConfigData, patchTarget: String): WurstProjectConfigData {
+ return alignOfficialStdlibDependency(configData.withWc3Patch(patchTarget), patchTarget)
+ }
+
+ internal fun alignOfficialStdlibDependency(configData: WurstProjectConfigData, patchTarget: String): WurstProjectConfigData {
+ val expectedStdlib = stdlibDependencyForPatch(patchTarget)
+ val dependencies = configData.dependencies.map { dependency ->
+ if (isOfficialStdlibDependency(dependency)) expectedStdlib else dependency
+ }.distinct()
+ return configData.withDependencies(dependencies)
+ }
+
+ private fun isOfficialStdlibDependency(dependency: String): Boolean {
+ val prefix = "https://github.com/wurstscript/wurstStdlib2"
+ if (!dependency.startsWith(prefix, ignoreCase = true)) {
+ return false
+ }
+ val suffix = dependency.substring(prefix.length)
+ val normalizedSuffix = if (suffix.startsWith(".git", ignoreCase = true)) suffix.substring(4) else suffix
+ return normalizedSuffix.isBlank() || normalizedSuffix.startsWith(":")
+ }
+
+ private fun handlePatchAlignment(configData: WurstProjectConfigData) {
+ val action = setup.commandArg.trim().lowercase()
+ if (action.isNotBlank() && action != "align") {
+ fail("Unknown patch action: ${setup.commandArg}. Use `grill patch` or `grill patch align`.")
+ ExitHandler.exit(1)
+ return
+ }
+
+ val configuredPath = WurstProjectConfig.configuredGamePath(setup.projectRoot)
+ val gameRoot = setup.gamePath ?: configuredPath ?: Wc3ClientDetector.detectGameRoot()
+ val clientInfo = Wc3ClientDetector.inspectGameRoot(gameRoot)
+ if (clientInfo == null) {
+ fail("No Warcraft III installation was found. Use `grill patch --wc3-path `.")
+ ExitHandler.exit(1)
+ return
+ }
+
+ val detectedPatch = clientInfo.patchTarget
+ val currentPatch = CoreJassProvider.patchLine(configData.wc3Patch)
+ log.info("Project patch: ${currentPatch ?: configData.wc3Patch ?: "not configured"}")
+ log.info("Detected client: ${Wc3ClientDetector.describe(clientInfo)}")
+ if (detectedPatch == null) {
+ fail("The exact Warcraft III patch could not be mapped to a supported target. No files were changed.")
+ ExitHandler.exit(1)
+ return
+ }
+
+ val alignedConfig = alignedProjectConfig(configData, detectedPatch)
+ val configNeedsAlignment = alignedConfig != configData
+ val coreJassNeedsRefresh = CoreJassProvider.managedFilesNeedRefresh(setup.projectRoot, detectedPatch)
+ if (!configNeedsAlignment && !coreJassNeedsRefresh) {
+ pass("Project is already aligned with Warcraft III $detectedPatch.")
+ return
+ }
+ if (action != "align") {
+ if (configNeedsAlignment) {
+ log.info("Alignment available: ${currentPatch ?: "unconfigured"} -> $detectedPatch")
+ }
+ if (coreJassNeedsRefresh) {
+ log.info("Managed core JASS needs to be refreshed for $detectedPatch.")
+ }
+ log.info("Run `grill patch align` to update wurst.build, stdlib, and core JASS.")
+ return
+ }
+
+ ensureCoreJassFiles(setup.projectRoot, detectedPatch)
+ if (configNeedsAlignment) {
+ val buildFile = setup.projectRoot.resolve(CONFIG_FILE_NAME)
+ Files.copy(buildFile, buildFile.resolveSibling("$CONFIG_FILE_NAME.bak"), StandardCopyOption.REPLACE_EXISTING)
+ WurstProjectConfig.handleUpdate(setup.projectRoot, clientInfo.root, alignedConfig)
+ pass("Aligned project with Warcraft III $detectedPatch. Previous config: $CONFIG_FILE_NAME.bak")
+ } else {
+ pass("Refreshed managed core JASS for Warcraft III $detectedPatch.")
+ }
+ }
+
+ private fun suggestPatchAlignment(configData: WurstProjectConfigData) {
+ val configuredPath = WurstProjectConfig.configuredGamePath(setup.projectRoot)
+ val gameRoot = setup.gamePath ?: configuredPath ?: Wc3ClientDetector.detectGameRoot()
+ val clientInfo = Wc3ClientDetector.inspectGameRoot(gameRoot) ?: return
+ Wc3ClientDetector.mismatchMessage(configData.wc3Patch, clientInfo)?.let(log::warn)
+ }
+
internal fun generatedBuildMapData(projectName: String): WurstProjectBuildMapData {
val mapName = projectName.trim().ifBlank { "Unnamed" }
return WurstProjectBuildMapData(
@@ -805,15 +909,16 @@ object SetupApp {
if (currentPatch.isNullOrBlank()) {
val selectedPatch = selectPatchVersionForInstall()
log.info("WC3 patch recorded in wurst.build: $selectedPatch")
- return configData.withWc3Patch(selectedPatch)
+ return alignOfficialStdlibDependency(configData.withWc3Patch(selectedPatch), selectedPatch)
}
val normalizedPatch = CoreJassProvider.normalizePatchInput(currentPatch)
- return if (normalizedPatch != currentPatch) {
+ val normalizedConfig = if (normalizedPatch != currentPatch) {
configData.withWc3Patch(normalizedPatch)
} else {
configData
}
+ return alignOfficialStdlibDependency(normalizedConfig, normalizedPatch)
}
internal fun selectPatchVersionForInstall(): String {
diff --git a/src/main/kotlin/file/Wc3ClientDetector.kt b/src/main/kotlin/file/Wc3ClientDetector.kt
index cae1cb6..292f795 100644
--- a/src/main/kotlin/file/Wc3ClientDetector.kt
+++ b/src/main/kotlin/file/Wc3ClientDetector.kt
@@ -15,13 +15,18 @@ object Wc3ClientDetector {
data class ClientInfo(
val root: Path,
+ val installationRoot: Path,
val executable: Path,
val kind: ClientKind?,
+ val version: String?,
+ val patchTarget: String?,
)
private val exeCandidates = listOf(
Paths.get("_retail_", "x86_64", "Warcraft III.exe"),
Paths.get("_retail_", "x86", "Warcraft III.exe"),
+ Paths.get("_ptr_", "x86_64", "Warcraft III.exe"),
+ Paths.get("_ptr_", "x86", "Warcraft III.exe"),
Paths.get("x86_64", "Warcraft III.exe"),
Paths.get("x86", "Warcraft III.exe"),
Paths.get("Warcraft III.exe"),
@@ -42,12 +47,16 @@ object Wc3ClientDetector {
}
val normalizedRoot = root.toAbsolutePath().normalize()
val executable = findExecutable(normalizedRoot) ?: return null
- val installRoot = if (Files.isRegularFile(normalizedRoot)) {
- installationRootForExecutable(executable)
- } else {
- normalizedRoot
- }
- return ClientInfo(installRoot, executable, classifyExecutable(executable))
+ val installRoot = installationRootForExecutable(executable)
+ val version = readBuildInfoVersion(installRoot, productForExecutable(executable))
+ return ClientInfo(
+ clientRootForExecutable(executable, installRoot),
+ installRoot,
+ executable,
+ classifyExecutable(executable),
+ version,
+ CoreJassProvider.patchTargetForClientVersion(version)
+ )
}
fun describe(info: ClientInfo?): String {
@@ -55,7 +64,8 @@ object Wc3ClientDetector {
return "not found"
}
val kind = info.kind?.let { describeKind(it) } ?: "unknown patch family"
- return "${info.root} ($kind)"
+ val version = info.version?.let { ", version $it" }.orEmpty()
+ return "${info.root} ($kind$version)"
}
fun projectKind(patch: String?): ClientKind? {
@@ -70,6 +80,11 @@ object Wc3ClientDetector {
fun mismatchMessage(projectPatch: String?, clientInfo: ClientInfo?): String? {
val projectKind = projectKind(projectPatch) ?: return null
val clientKind = clientInfo?.kind ?: return null
+ val projectPatchTarget = CoreJassProvider.patchLine(projectPatch)
+ val clientPatchTarget = clientInfo.patchTarget
+ if (projectPatchTarget != null && clientPatchTarget != null && projectPatchTarget != clientPatchTarget) {
+ return "Selected Warcraft III client is $clientPatchTarget (${clientInfo.version}), but the project targets $projectPatchTarget. Run `grill patch align` to migrate the project."
+ }
if (projectKind == clientKind) {
return null
}
@@ -121,6 +136,59 @@ object Wc3ClientDetector {
return parent
}
+ private fun clientRootForExecutable(executable: Path, installRoot: Path): Path {
+ val executableDirectory = executable.toAbsolutePath().normalize().parent ?: return installRoot
+ val channelDirectory = if (
+ executableDirectory.fileName?.toString()?.equals("x86", ignoreCase = true) == true ||
+ executableDirectory.fileName?.toString()?.equals("x86_64", ignoreCase = true) == true
+ ) {
+ executableDirectory.parent
+ } else {
+ executableDirectory
+ }
+ return channelDirectory?.takeIf {
+ it.fileName?.toString()?.equals("_retail_", ignoreCase = true) == true ||
+ it.fileName?.toString()?.equals("_ptr_", ignoreCase = true) == true
+ } ?: installRoot
+ }
+
+ private fun productForExecutable(executable: Path): String? {
+ val path = executable.toAbsolutePath().normalize().toString().replace('\\', '/').lowercase(Locale.ROOT)
+ return when {
+ path.contains("/_ptr_/") -> "w3t"
+ path.contains("/_retail_/") -> "w3"
+ else -> null
+ }
+ }
+
+ private fun readBuildInfoVersion(root: Path, selectedProduct: String?): String? {
+ val buildInfo = root.resolve(".build.info")
+ if (!Files.isRegularFile(buildInfo)) {
+ return null
+ }
+ return try {
+ val lines = Files.readAllLines(buildInfo).filter(String::isNotBlank)
+ val headers = lines.firstOrNull()?.split('|')?.map { it.substringBefore('!') } ?: return null
+ val versionIndex = headers.indexOf("Version")
+ val productIndex = headers.indexOf("Product")
+ val activeIndex = headers.indexOf("Active")
+ if (versionIndex < 0 || productIndex < 0 || activeIndex < 0) return null
+
+ lines.drop(1)
+ .map { it.split('|') }
+ .firstOrNull { values ->
+ values.size > versionIndex &&
+ values.getOrNull(productIndex).equals(selectedProduct ?: "w3", ignoreCase = true) &&
+ values.getOrNull(activeIndex) == "1"
+ }
+ ?.getOrNull(versionIndex)
+ ?.trim()
+ ?.takeIf(String::isNotBlank)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
private fun describeKind(kind: ClientKind): String {
return when (kind) {
ClientKind.PRE_129 -> "pre-1.29"
diff --git a/src/main/kotlin/file/YamlHelper.kt b/src/main/kotlin/file/YamlHelper.kt
index 43b8229..c3afe12 100644
--- a/src/main/kotlin/file/YamlHelper.kt
+++ b/src/main/kotlin/file/YamlHelper.kt
@@ -39,11 +39,13 @@ object YamlHelper {
}
- fun loadProjectConfig(path: Path): WurstProjectConfigData {
+ fun loadProjectConfig(path: Path, persistRecovery: Boolean = true): WurstProjectConfigData {
val content = Files.readString(path)
if (isEffectivelyEmptyYaml(content)) {
val fallback = fallbackConfig(path)
- persistRecoveredConfig(path, fallback, backupOriginal = false)
+ if (persistRecovery) {
+ persistRecoveredConfig(path, fallback, backupOriginal = false)
+ }
return fallback
}
@@ -54,7 +56,9 @@ object YamlHelper {
} catch (e: Exception) {
log.warn("The project's wurst.build file could not be read. Recovering with defaults.", e)
val fallback = fallbackConfig(path)
- persistRecoveredConfig(path, fallback, backupOriginal = true)
+ if (persistRecovery) {
+ persistRecoveredConfig(path, fallback, backupOriginal = true)
+ }
fallback
}
}
diff --git a/src/test/kotlin/GenerateTests.kt b/src/test/kotlin/GenerateTests.kt
index d284117..f5c53cc 100644
--- a/src/test/kotlin/GenerateTests.kt
+++ b/src/test/kotlin/GenerateTests.kt
@@ -148,6 +148,7 @@ class GenerateTests {
fun testStdlibDependencyFollowsPatchEra() {
val pre124Stdlib = "https://github.com/wurstscript/wurstStdlib2:pre1.24"
val legacyStdlib = "https://github.com/wurstscript/wurstStdlib2:pre1.29"
+ val preV3Stdlib = "https://github.com/wurstscript/wurstStdlib2:v2.0"
val currentStdlib = "https://github.com/wurstscript/wurstStdlib2"
for (patch in CoreJassProvider.supportedPatches) {
@@ -155,7 +156,8 @@ class GenerateTests {
val expected = when {
minor != null && minor < 24 -> pre124Stdlib
minor != null && minor < 29 -> legacyStdlib
- else -> currentStdlib
+ patch == "v3.0" -> currentStdlib
+ else -> preV3Stdlib
}
Assert.assertEquals(SetupApp.stdlibDependencyForPatch(patch), expected, "stdlib dependency for $patch")
}
@@ -163,7 +165,15 @@ class GenerateTests {
Assert.assertEquals(SetupApp.stdlibDependencyForPatch("v1.23a"), pre124Stdlib)
Assert.assertEquals(SetupApp.stdlibDependencyForPatch("TFT-v1.27b-ru"), legacyStdlib)
Assert.assertEquals(SetupApp.stdlibDependencyForPatch("pre1.29"), legacyStdlib)
- Assert.assertEquals(SetupApp.stdlibDependencyForPatch("v1.29"), currentStdlib)
+ Assert.assertEquals(SetupApp.stdlibDependencyForPatch("v1.29"), preV3Stdlib)
+ Assert.assertEquals(
+ SetupApp.stdlibDependencyForPatch("Reforged-v3.1.0.25000-w3-deadbeef"),
+ currentStdlib
+ )
+ Assert.assertEquals(
+ SetupApp.stdlibDependencyForPatch("Reforged-v2.0.4.23745-w3-deadbeef"),
+ preV3Stdlib
+ )
}
@Test(priority = 10)
diff --git a/src/test/kotlin/PatchAlignmentTests.kt b/src/test/kotlin/PatchAlignmentTests.kt
new file mode 100644
index 0000000..e177c0f
--- /dev/null
+++ b/src/test/kotlin/PatchAlignmentTests.kt
@@ -0,0 +1,170 @@
+import config.newProjectConfig
+import config.WurstProjectConfig
+import file.CoreJassProvider
+import file.CLICommand
+import file.SetupApp
+import file.SetupMain
+import org.testng.Assert
+import org.testng.annotations.Test
+import java.nio.file.Files
+import java.nio.file.Paths
+
+class PatchAlignmentTests {
+ @Test
+ fun testParsesPatchAlignWithExplicitGamePath() {
+ val setup = SetupMain()
+ val gamePath = Paths.get("C:\\Games\\Warcraft III")
+
+ setup.parseArgs(listOf("patch", "align", "--wc3-path", gamePath.toString()))
+
+ Assert.assertEquals(setup.command, CLICommand.PATCH)
+ Assert.assertEquals(setup.commandArg, "align")
+ Assert.assertEquals(setup.gamePath, gamePath)
+ }
+
+ @Test
+ fun testMapsClientVersionsToSupportedPatchLines() {
+ Assert.assertEquals(CoreJassProvider.patchTargetForClientVersion("3.0.0.24268"), "v3.0")
+ Assert.assertEquals(CoreJassProvider.patchTargetForClientVersion("2.0.4.23745"), "v2.0")
+ Assert.assertEquals(CoreJassProvider.patchTargetForClientVersion("1.36.1.20719"), "v1.36")
+ Assert.assertNull(CoreJassProvider.patchTargetForClientVersion("4.0.0.1"))
+ }
+
+ @Test
+ fun testV2ProjectsUseTheMaintenanceStdlibBranch() {
+ Assert.assertEquals(
+ SetupApp.stdlibDependencyForPatch("v2.0"),
+ "https://github.com/wurstscript/wurstStdlib2:v2.0"
+ )
+ Assert.assertEquals(
+ SetupApp.stdlibDependencyForPatch("v3.0"),
+ "https://github.com/wurstscript/wurstStdlib2"
+ )
+ Assert.assertEquals(
+ SetupApp.stdlibDependencyForPatch("v1.36"),
+ "https://github.com/wurstscript/wurstStdlib2:v2.0"
+ )
+ }
+
+ @Test
+ fun testAlignmentUpdatesPatchAndOfficialStdlibTogether() {
+ val config = newProjectConfig(
+ projectName = "migration-test",
+ dependencies = listOf(
+ "https://github.com/wurstscript/wurstStdlib2:v2.0",
+ "https://github.com/example/custom-library"
+ ),
+ wc3Patch = "v2.0"
+ )
+
+ val aligned = SetupApp.alignedProjectConfig(config, "v3.0")
+
+ Assert.assertEquals(aligned.wc3Patch, "v3.0")
+ Assert.assertEquals(
+ aligned.dependencies,
+ listOf(
+ "https://github.com/wurstscript/wurstStdlib2",
+ "https://github.com/example/custom-library"
+ )
+ )
+ }
+
+ @Test
+ fun testAlignmentDoesNotRewriteCustomStdlibForks() {
+ val customFork = "https://github.com/example/wurstStdlib2:custom"
+ val config = newProjectConfig(
+ projectName = "custom-stdlib",
+ dependencies = listOf(customFork),
+ wc3Patch = "v2.0"
+ )
+
+ val aligned = SetupApp.alignedProjectConfig(config, "v3.0")
+
+ Assert.assertEquals(aligned.dependencies, listOf(customFork))
+ }
+
+ @Test
+ fun testAlignmentRecognizesOfficialStdlibGitUrls() {
+ val config = newProjectConfig(
+ projectName = "git-url",
+ dependencies = listOf(
+ "https://github.com/wurstscript/wurstStdlib2.git",
+ "https://github.com/wurstscript/wurstStdlib2.git:master"
+ ),
+ wc3Patch = "v3.0"
+ )
+
+ val aligned = SetupApp.alignedProjectConfig(config, "v2.0")
+
+ Assert.assertEquals(
+ aligned.dependencies,
+ listOf("https://github.com/wurstscript/wurstStdlib2:v2.0")
+ )
+ }
+
+ @Test
+ fun testExistingV2TargetPinsStdlibBeforeDependencyInstall() {
+ val config = newProjectConfig(
+ projectName = "safe-install",
+ dependencies = listOf("https://github.com/wurstscript/wurstStdlib2"),
+ wc3Patch = "v2.0"
+ )
+
+ val aligned = SetupApp.alignOfficialStdlibDependency(config, "v2.0")
+
+ Assert.assertEquals(aligned.wc3Patch, "v2.0")
+ Assert.assertEquals(
+ aligned.dependencies,
+ listOf("https://github.com/wurstscript/wurstStdlib2:v2.0")
+ )
+ }
+
+ @Test
+ fun testManagedCoreJassDetectsStalePatchAndInvalidFiles() {
+ val projectRoot = Files.createTempDirectory("wurstsetup-core-jass-alignment")
+ val buildFolder = Files.createDirectories(projectRoot.resolve("_build"))
+ Files.writeString(buildFolder.resolve("core-jass.provenance"), "wc3Patch: v2.0\n")
+ Files.writeString(buildFolder.resolve("common.j"), "x".repeat(2048))
+ Files.writeString(buildFolder.resolve("blizzard.j"), "x".repeat(2048))
+
+ Assert.assertTrue(CoreJassProvider.managedFilesNeedRefresh(projectRoot, "v3.0"))
+ Assert.assertFalse(CoreJassProvider.managedFilesNeedRefresh(projectRoot, "v2.0"))
+
+ Files.writeString(buildFolder.resolve("common.j"), "invalid")
+ Assert.assertTrue(CoreJassProvider.managedFilesNeedRefresh(projectRoot, "v2.0"))
+ }
+
+ @Test
+ fun testConfiguredGamePathSupportsJsonc() {
+ val projectRoot = Files.createTempDirectory("wurstsetup-jsonc-settings")
+ val vscodeFolder = Files.createDirectories(projectRoot.resolve(".vscode"))
+ Files.writeString(
+ vscodeFolder.resolve("settings.json"),
+ """
+ {
+ // Warcraft III installation used by the Wurst extension.
+ "wurst.wc3path": "C:\\Games\\Warcraft III",
+ }
+ """.trimIndent()
+ )
+
+ Assert.assertEquals(
+ WurstProjectConfig.configuredGamePath(projectRoot),
+ Paths.get("C:\\Games\\Warcraft III")
+ )
+ }
+
+ @Test
+ fun testReadOnlyProjectLoadDoesNotRepairMalformedBuildFile() {
+ val projectRoot = Files.createTempDirectory("wurstsetup-read-only-patch")
+ val buildFile = projectRoot.resolve("wurst.build")
+ val malformedConfig = ":\n - [broken"
+ Files.writeString(buildFile, malformedConfig)
+
+ val loaded = WurstProjectConfig.loadProject(buildFile, persistRecovery = false)
+
+ Assert.assertNotNull(loaded)
+ Assert.assertEquals(Files.readString(buildFile), malformedConfig)
+ Assert.assertFalse(Files.exists(projectRoot.resolve("wurst.build.bak")))
+ }
+}
diff --git a/src/test/kotlin/Wc3ClientDetectorTests.kt b/src/test/kotlin/Wc3ClientDetectorTests.kt
index ea1caf8..0a489eb 100644
--- a/src/test/kotlin/Wc3ClientDetectorTests.kt
+++ b/src/test/kotlin/Wc3ClientDetectorTests.kt
@@ -13,7 +13,8 @@ class Wc3ClientDetectorTests {
val info = Wc3ClientDetector.inspectGameRoot(root)!!
Assert.assertEquals(info.kind, Wc3ClientDetector.ClientKind.REFORGED)
- Assert.assertEquals(info.root, root.toAbsolutePath().normalize())
+ Assert.assertEquals(info.root, root.resolve("_retail_").toAbsolutePath().normalize())
+ Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize())
}
@Test
@@ -58,6 +59,7 @@ class Wc3ClientDetectorTests {
Assert.assertEquals(info.kind, Wc3ClientDetector.ClientKind.PRE_129)
Assert.assertEquals(info.root, root.toAbsolutePath().normalize())
+ Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize())
}
@Test
@@ -71,4 +73,140 @@ class Wc3ClientDetectorTests {
Assert.assertNotNull(warning)
Assert.assertTrue(warning!!.contains("project targets Reforged"))
}
+
+ @Test
+ fun testReadsExactReforgedVersionFromBuildInfo() {
+ val root = Files.createTempDirectory("wc3-versioned-reforged")
+ val exe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe")
+ Files.writeString(exe, "")
+ Files.writeString(
+ root.resolve(".build.info"),
+ "Branch!STRING:0|Active!DEC:1|Version!STRING:0|Product!STRING:0\n" +
+ "eu|1|3.0.0.24268|w3\n"
+ )
+
+ val info = Wc3ClientDetector.inspectGameRoot(root)!!
+
+ Assert.assertEquals(info.version, "3.0.0.24268")
+ Assert.assertEquals(info.patchTarget, "v3.0")
+ Assert.assertNull(Wc3ClientDetector.mismatchMessage("v3.0", info))
+ Assert.assertTrue(Wc3ClientDetector.mismatchMessage("v2.0", info)!!.contains("grill patch align"))
+ }
+
+ @Test
+ fun testFindsBuildInfoWhenConfiguredPathIsExecutableDirectory() {
+ val root = Files.createTempDirectory("wc3-configured-bin")
+ val executableDirectory = Files.createDirectories(root.resolve("_retail_").resolve("x86_64"))
+ Files.writeString(executableDirectory.resolve("Warcraft III.exe"), "")
+ Files.writeString(
+ root.resolve(".build.info"),
+ "Active!DEC:1|Version!STRING:0|Product!STRING:0\n" +
+ "1|3.0.0.24268|w3\n"
+ )
+
+ val info = Wc3ClientDetector.inspectGameRoot(executableDirectory)!!
+
+ Assert.assertEquals(info.root, root.resolve("_retail_").toAbsolutePath().normalize())
+ Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize())
+ Assert.assertEquals(info.patchTarget, "v3.0")
+ }
+
+ @Test
+ fun testIgnoresInactiveAndNonWarcraftBuildInfoRows() {
+ val root = Files.createTempDirectory("wc3-multi-product")
+ val exe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe")
+ Files.writeString(exe, "")
+ Files.writeString(
+ root.resolve(".build.info"),
+ "Active!DEC:1|Version!STRING:0|Product!STRING:0\n" +
+ "1|99.0.0.1|other\n" +
+ "0|2.0.4.23745|w3\n" +
+ "1|3.0.0.24268|w3\n"
+ )
+
+ val info = Wc3ClientDetector.inspectGameRoot(root)!!
+
+ Assert.assertEquals(info.version, "3.0.0.24268")
+ Assert.assertEquals(info.patchTarget, "v3.0")
+ }
+
+ @Test
+ fun testRejectsBuildInfoWithoutProductHeader() {
+ val root = Files.createTempDirectory("wc3-build-info-no-product")
+ val exe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe")
+ Files.writeString(exe, "")
+ Files.writeString(
+ root.resolve(".build.info"),
+ "Active!DEC:1|Version!STRING:0\n" +
+ "1|3.0.0.24268\n"
+ )
+
+ val info = Wc3ClientDetector.inspectGameRoot(root)!!
+
+ Assert.assertNull(info.version)
+ Assert.assertNull(info.patchTarget)
+ }
+
+ @Test
+ fun testRejectsBuildInfoWithoutActiveHeader() {
+ val root = Files.createTempDirectory("wc3-build-info-no-active")
+ val exe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe")
+ Files.writeString(exe, "")
+ Files.writeString(
+ root.resolve(".build.info"),
+ "Version!STRING:0|Product!STRING:0\n" +
+ "3.0.0.24268|w3\n"
+ )
+
+ val info = Wc3ClientDetector.inspectGameRoot(root)!!
+
+ Assert.assertNull(info.version)
+ Assert.assertNull(info.patchTarget)
+ }
+
+ @Test
+ fun testSelectsPtrBuildInfoRowForExplicitPtrPath() {
+ val root = Files.createTempDirectory("wc3-retail-ptr")
+ val retailExe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe")
+ val ptrDirectory = Files.createDirectories(root.resolve("_ptr_").resolve("x86_64"))
+ Files.writeString(retailExe, "")
+ Files.writeString(ptrDirectory.resolve("Warcraft III.exe"), "")
+ Files.writeString(
+ root.resolve(".build.info"),
+ "Active!DEC:1|Version!STRING:0|Product!STRING:0\n" +
+ "1|3.0.0.24268|w3\n" +
+ "1|2.0.4.23745|w3t\n"
+ )
+
+ val info = Wc3ClientDetector.inspectGameRoot(ptrDirectory)!!
+
+ Assert.assertEquals(info.executable, ptrDirectory.resolve("Warcraft III.exe"))
+ Assert.assertEquals(info.root, ptrDirectory.parent)
+ Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize())
+ Assert.assertEquals(info.version, "2.0.4.23745")
+ Assert.assertEquals(info.patchTarget, "v2.0")
+ }
+
+ @Test
+ fun testSelectsRetailBuildInfoRowForInstallationRoot() {
+ val root = Files.createTempDirectory("wc3-retail-ptr-root")
+ val retailDirectory = Files.createDirectories(root.resolve("_retail_").resolve("x86_64"))
+ val ptrDirectory = Files.createDirectories(root.resolve("_ptr_").resolve("x86_64"))
+ Files.writeString(retailDirectory.resolve("Warcraft III.exe"), "")
+ Files.writeString(ptrDirectory.resolve("Warcraft III.exe"), "")
+ Files.writeString(
+ root.resolve(".build.info"),
+ "Active!DEC:1|Version!STRING:0|Product!STRING:0\n" +
+ "1|2.0.4.23745|w3t\n" +
+ "1|3.0.0.24268|w3\n"
+ )
+
+ val info = Wc3ClientDetector.inspectGameRoot(root)!!
+
+ Assert.assertEquals(info.executable, retailDirectory.resolve("Warcraft III.exe"))
+ Assert.assertEquals(info.root, retailDirectory.parent)
+ Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize())
+ Assert.assertEquals(info.version, "3.0.0.24268")
+ Assert.assertEquals(info.patchTarget, "v3.0")
+ }
}