Skip to content
Merged
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
47 changes: 45 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,50 @@ This pipeline handles:

---

## 8. Backend Parity and Lua Guardrails
## 8. Shared Project Config, Patch Targets, and Run Behavior

Recent Grill/compiler integration work moved `wurst.build` parsing rules into a tiny shared dependency. Keep compiler behavior aligned with that shared model.

### Shared project config dependency

* `de.peeeq.wurstscript/build.gradle` depends on `com.github.wurstscript:wurst-project-config`.
* `de.peeeq.wurstio.languageserver.WurstBuildConfig` is a compiler adapter around the shared model, not a second config DAO.
* Do not duplicate YAML parsing rules, patch aliases, or script-mode behavior in compiler-only code unless it is truly compiler-specific.
* Preserve exact `wc3Patch` names for cache invalidation and diagnostics. Broad patch kind is useful for behavior choices, but not enough for hashes.

### Patch target rules

* Use the shared `Wc3PatchTarget` parser for `wc3Patch`.
* Patch family boundaries:
* below `1.29` => pre-1.29 behavior
* `1.29` through `1.31` => classic
* `1.32+`, `1.36`, `2.0`, and `Reforged-*` => Reforged
* Friendly names and jass-history dump names should resolve through shared config. Do not add one-off aliases in compiler code.
* If jass-history has a broken folder name, fix `wurstscript/jass-history` instead of compensating here.

### Build vs run

* Build/typecheck should prefer pinned `wc3Patch` from `wurst.build` and should not parse the installed Warcraft executable just to decide target patch data.
* Config injection should use the pinned project patch when available, not the locally installed game patch.
* User-facing executable version parsing failures must stay short. Do not print PE parser stack traces unless explicit debug logging is requested.
* Run/launch is different from build: the selected Warcraft executable controls launch arguments and map placement.
* When project patch family and selected client family differ, warn and allow the user to choose a different Warcraft III folder.
* If launch folder selection changes the client, all launch decisions must use that selected `W3InstallationData`, not stale request-level `w3data`.
* Legacy clients that need install-dir map placement must copy to the selected launch install's `Maps/Test` folder.

### Focused tests

For config and run-pipeline changes, prefer these focused checks before broader test runs:

```
./gradlew test --tests tests.wurstscript.tests.WurstBuildConfigTests
./gradlew test --tests tests.wurstscript.tests.MapRequestPatchTargetTests
./gradlew make_for_userdir
```

---

## 9. Backend Parity and Lua Guardrails

Recent fixes established additional rules for backend work. Follow these for all future changes:

Expand Down Expand Up @@ -288,7 +331,7 @@ Recent fixes established additional rules for backend work. Follow these for all

---

## 9. Virtual Slot Binding and Determinism (New Generics + Lua)
## 10. Virtual Slot Binding and Determinism (New Generics + Lua)

Recent regressions showed that virtual-slot binding can silently degrade to base/no-op implementations in generated Lua while still compiling. Follow these rules for all related changes:

Expand Down
1 change: 1 addition & 0 deletions de.peeeq.wurstscript/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ dependencies {
implementation 'org.xerial:sqlite-jdbc:3.46.1.3'
implementation 'com.github.inwc3:jmpq3:e28f6999c0'
implementation 'com.github.inwc3:wc3libs:a69318d921'
implementation 'com.github.wurstscript:wurst-project-config:2c7ccd1a5f'
implementation('com.github.wurstscript:wurstsetup:393cf5ea39') {
exclude group: 'org.eclipse.jgit', module: 'org.eclipse.jgit'
exclude group: 'org.eclipse.jgit', module: 'org.eclipse.jgit.ssh.apache'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ private static String calculateProjectConfigHash(WurstProjectConfigData projectC
.append("\n");
WurstBuildConfig buildConfig = buildConfigFromBuildDir(buildDir);
sb.append("scriptMode:").append(buildConfig.scriptMode()).append("\n");
sb.append("wc3Patch:").append(buildConfig.wc3Patch()).append("\n");
sb.append("wc3Patch:").append(buildConfig.wc3PatchName()).append("\n");

return ImportFile.calculateHash(sb.toString().getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
Expand All @@ -200,21 +200,29 @@ private static void applyBuildMapData(WurstProjectConfigData projectConfig, File
try (FileInputStream inputStream = new FileInputStream(mapScript)) {
StringWriter sw = new StringWriter();

if (w3data.getWc3PatchVersion().isPresent()) {
w3I.injectConfigsInJassScript(inputStream, sw, w3data.getWc3PatchVersion().get());
WurstBuildConfig buildConfig = buildConfigFromBuildDir(buildDir);
GameVersion version = effectiveConfigInjectionVersion(buildDir, w3data);
if (buildConfig.configuredGameVersion().isPresent()) {
WLogger.info("Using wurst.build patch target for map config injection: " + version);
} else if (w3data.getWc3PatchVersion().isPresent()) {
WLogger.info("Using detected game version for map config injection: " + version);
} else {
GameVersion version = buildConfigFromBuildDir(buildDir).fallbackGameVersion();
WLogger.info(
"Failed to determine installed game version. Falling back to wurst.build patch target: " + version
);
w3I.injectConfigsInJassScript(inputStream, sw, version);
WLogger.info("Failed to determine installed game version. Falling back to default patch target: " + version);
}
w3I.injectConfigsInJassScript(inputStream, sw, version);

byte[] scriptBytes = sw.toString().getBytes(StandardCharsets.UTF_8);
Files.write(scriptBytes, result.script);
}
}

private static GameVersion effectiveConfigInjectionVersion(File buildDir, W3InstallationData w3data) {
WurstBuildConfig buildConfig = buildConfigFromBuildDir(buildDir);
return buildConfig.configuredGameVersion()
.or(() -> w3data.getWc3PatchVersion())
.orElseGet(buildConfig::fallbackGameVersion);
}

private static WurstBuildConfig buildConfigFromBuildDir(File buildDir) {
java.nio.file.Path projectRoot = buildDir.toPath().getParent();
if (projectRoot == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
import config.WurstProjectConfigData;
import de.peeeq.wurstscript.WLogger;
import net.moonlightflower.wc3libs.port.GameVersion;
import org.wurstscript.projectconfig.Wc3PatchTarget;

import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

import static de.peeeq.wurstio.languageserver.ProjectConfigBuilder.FILE_NAME;
Expand All @@ -24,181 +22,133 @@ public enum ScriptMode {

public enum Wc3Patch {
REFORGED,
CLASSIC,
PRE_129
}

private final Optional<ScriptMode> scriptMode;
private final Optional<Wc3Patch> wc3Patch;
private final org.wurstscript.projectconfig.WurstBuildConfig sharedConfig;

private WurstBuildConfig(Optional<ScriptMode> scriptMode, Optional<Wc3Patch> wc3Patch) {
this.scriptMode = scriptMode;
this.wc3Patch = wc3Patch;
private WurstBuildConfig(org.wurstscript.projectconfig.WurstBuildConfig sharedConfig) {
this.sharedConfig = sharedConfig == null ? org.wurstscript.projectconfig.WurstBuildConfig.empty() : sharedConfig;
}

public static WurstBuildConfig empty() {
return new WurstBuildConfig(Optional.empty(), Optional.empty());
return new WurstBuildConfig(org.wurstscript.projectconfig.WurstBuildConfig.empty());
}

public static WurstBuildConfig fromWorkspaceRoot(WFile workspaceRoot) {
if (workspaceRoot == null) {
return empty();
}
return fromBuildFile(Path.of(workspaceRoot.toString(), FILE_NAME));
}

public static WurstBuildConfig fromProject(WurstProjectConfigData projectConfig, WFile workspaceRoot) {
WurstBuildConfig fileConfig = fromWorkspaceRoot(workspaceRoot);
Optional<ScriptMode> scriptMode = readEnumGetter(projectConfig, "getScriptMode", ScriptMode::valueOf)
.or(fileConfig::scriptMode);
Optional<Wc3Patch> wc3Patch = readEnumGetter(projectConfig, "getWc3Patch", WurstBuildConfig::parsePatchName)
.or(fileConfig::wc3Patch);
return new WurstBuildConfig(scriptMode, wc3Patch);
if (projectConfig == null) {
return fileConfig;
}
Optional<org.wurstscript.projectconfig.ScriptMode> scriptMode = readStringGetter(projectConfig, "getScriptMode")
.flatMap(WurstBuildConfig::parseSharedScriptMode)
.or(fileConfig.sharedConfig::scriptMode);
Optional<Wc3PatchTarget> wc3Patch = readStringGetter(projectConfig, "getWc3Patch")
.flatMap(Wc3PatchTarget::parse)
.or(fileConfig.sharedConfig::wc3Patch);
return new WurstBuildConfig(new org.wurstscript.projectconfig.WurstBuildConfig(scriptMode, wc3Patch));
}

static WurstBuildConfig fromBuildFile(Path buildFile) {
if (!Files.exists(buildFile)) {
return empty();
}
Optional<ScriptMode> scriptMode = Optional.empty();
Optional<Wc3Patch> wc3Patch = Optional.empty();
try {
for (String rawLine : Files.readAllLines(buildFile)) {
String line = stripComment(rawLine).trim();
if (line.isEmpty() || Character.isWhitespace(rawLine.charAt(0))) {
continue;
}
int colon = line.indexOf(':');
if (colon < 0) {
continue;
}
String key = line.substring(0, colon).trim();
String value = normalizeScalar(line.substring(colon + 1).trim());
if (key.equals("scriptMode")) {
scriptMode = parseScriptMode(value);
} else if (key.equals("wc3Patch")) {
wc3Patch = parsePatch(value);
}
}
return new WurstBuildConfig(org.wurstscript.projectconfig.WurstBuildConfig.fromBuildFile(buildFile));
} catch (IOException e) {
WLogger.warning("Could not read " + buildFile + " for build settings", e);
return empty();
}
return new WurstBuildConfig(scriptMode, wc3Patch);
}

public Optional<ScriptMode> scriptMode() {
return scriptMode;
return sharedConfig.scriptMode().map(mode -> ScriptMode.valueOf(mode.name()));
}

public Optional<Wc3Patch> wc3Patch() {
return wc3Patch;
return sharedConfig.wc3Patch().map(WurstBuildConfig::patchKind);
Comment thread
Frotty marked this conversation as resolved.
}

public Optional<String> wc3PatchName() {
return sharedConfig.wc3Patch().map(Wc3PatchTarget::name);
}

public Optional<GameVersion> configuredGameVersion() {
return sharedConfig.wc3Patch()
.map(Wc3PatchTarget::gameVersion)
.map(GameVersion::new);
}

public Wc3Patch wc3PatchOrReforged() {
return wc3Patch.orElse(Wc3Patch.REFORGED);
return wc3Patch().orElse(Wc3Patch.REFORGED);
}

public GameVersion fallbackGameVersion() {
if (wc3PatchOrReforged() == Wc3Patch.PRE_129) {
return new GameVersion("1.28");
}
return GameVersion.VERSION_1_32;
return configuredGameVersion().orElse(GameVersion.VERSION_1_32);
}

public List<String> applyToCompileArgs(List<String> compileArgs) {
if (!scriptMode.isPresent()) {
return compileArgs;
}
List<String> result = new ArrayList<>();
for (String arg : compileArgs) {
if (!"-lua".equals(arg)) {
result.add(arg);
}
}
if (scriptMode.get() == ScriptMode.LUA) {
result.add("-lua");
}
return result;
return sharedConfig.applyToCompileArgs(compileArgs);
}

public boolean shouldUseReforgedLaunchArgs(Optional<GameVersion> detectedVersion) {
return detectedVersion
.map(version -> version.compareTo(GameVersion.VERSION_1_32) >= 0)
.orElse(wc3PatchOrReforged() == Wc3Patch.REFORGED);
return sharedConfig.shouldUseReforgedLaunchArgs(versionString(detectedVersion));
}

public boolean shouldUseClassicWindowArg(Optional<GameVersion> detectedVersion) {
return detectedVersion
.map(version -> version.compareTo(GameVersion.VERSION_1_31) < 0)
.orElse(wc3PatchOrReforged() == Wc3Patch.PRE_129);
return sharedConfig.shouldUseClassicWindowArg(versionString(detectedVersion));
}

public boolean shouldCopyRunMapToWarcraftMapDir(Optional<GameVersion> detectedVersion) {
return detectedVersion
.map(version -> version.compareTo(GameVersion.VERSION_1_32) < 0)
.orElse(wc3PatchOrReforged() == Wc3Patch.PRE_129);
return sharedConfig.shouldCopyRunMapToWarcraftMapDir(versionString(detectedVersion));
}

public boolean shouldUseInstallDirForMaps(Optional<GameVersion> detectedVersion) {
return detectedVersion.orElseGet(this::fallbackGameVersion)
.compareTo(new GameVersion("1.27.9")) <= 0;
Optional<GameVersion> effectiveVersion = detectedVersion == null ? Optional.empty() : detectedVersion;
return effectiveVersion.or(this::configuredGameVersion)
.map(version -> version.compareTo(new GameVersion("1.27.9")) <= 0)
.orElse(false);
}

private static String stripComment(String line) {
int commentStart = line.indexOf('#');
return commentStart >= 0 ? line.substring(0, commentStart) : line;
}

private static String normalizeScalar(String value) {
String result = value;
if ((result.startsWith("\"") && result.endsWith("\""))
|| (result.startsWith("'") && result.endsWith("'"))) {
result = result.substring(1, result.length() - 1);
}
return result.trim();
private static Optional<String> versionString(Optional<GameVersion> version) {
return version == null ? Optional.empty() : version.map(GameVersion::toString);
}

private static Optional<ScriptMode> parseScriptMode(String value) {
String normalized = value.toUpperCase(Locale.ROOT);
private static Optional<org.wurstscript.projectconfig.ScriptMode> parseSharedScriptMode(String value) {
try {
return Optional.of(ScriptMode.valueOf(normalized));
} catch (IllegalArgumentException e) {
WLogger.warning("Ignoring unknown scriptMode in wurst.build: " + value);
return Optional.of(org.wurstscript.projectconfig.ScriptMode.valueOf(value.trim().toUpperCase()));
} catch (IllegalArgumentException | NullPointerException e) {
return Optional.empty();
}
}

private static Optional<Wc3Patch> parsePatch(String value) {
try {
return Optional.of(parsePatchName(value));
} catch (IllegalArgumentException e) {
WLogger.warning("Ignoring unknown wc3Patch in wurst.build: " + value);
return Optional.empty();
private static Wc3Patch patchKind(Wc3PatchTarget target) {
if (target.kind() == Wc3PatchTarget.Kind.PRE_129) {
return Wc3Patch.PRE_129;
}
}

private static Wc3Patch parsePatchName(String value) {
String normalized = value.toUpperCase(Locale.ROOT)
.replace(".", "_")
.replace("-", "_");
if (normalized.equals("PRE1_29")) {
normalized = "PRE_129";
if (target.kind() == Wc3PatchTarget.Kind.CLASSIC) {
return Wc3Patch.CLASSIC;
}
return Wc3Patch.valueOf(normalized);
}

private interface EnumParser<T> {
T parse(String value);
return Wc3Patch.REFORGED;
}

private static <T> Optional<T> readEnumGetter(WurstProjectConfigData projectConfig, String getterName, EnumParser<T> parser) {
private static Optional<String> readStringGetter(WurstProjectConfigData projectConfig, String getterName) {
try {
Method getter = projectConfig.getClass().getMethod(getterName);
Object value = getter.invoke(projectConfig);
if (value == null) {
return Optional.empty();
}
return Optional.of(parser.parse(value.toString()));
return Optional.of(value.toString());
} catch (NoSuchMethodException ignored) {
return Optional.empty();
} catch (Exception e) {
WLogger.warning("Could not read " + getterName + " from wurst.build config", e);
WLogger.debug("Could not read " + getterName + " from wurst.build config: " + e);
return Optional.empty();
}
}
Expand Down
Loading
Loading