From bde3a821558cd16b028e78dab921c83e58210dca Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 7 Apr 2026 11:50:01 +0200 Subject: [PATCH 1/6] lua cleanup --- .../peeeq/wurstio/WurstCompilerJassImpl.java | 7 + .../languageserver/requests/MapRequest.java | 1 + .../imtranslation/LuaNativeLowering.java | 245 ++++++++++ .../lua/translation/LuaAssertions.java | 139 ++++++ .../lua/translation/LuaNatives.java | 5 + .../lua/translation/LuaPolyfillSetup.java | 221 +++++++++ .../lua/translation/LuaTranslator.java | 431 ++---------------- .../tests/LuaTranslationTests.java | 98 +++- 8 files changed, 751 insertions(+), 396 deletions(-) create mode 100644 de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java create mode 100644 de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaAssertions.java create mode 100644 de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaPolyfillSetup.java diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java index b38336679..60f0b1d34 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java @@ -889,6 +889,13 @@ public LuaCompilationUnit transformProgToLua() { } ImTranslator imTranslator2 = getImTranslator(); ImOptimizer optimizer = new ImOptimizer(timeTaker, imTranslator2); + + // Lower Lua-specific native calls into IM-level wrappers before optimization, + // so the optimizer can inline and eliminate the nil-safety checks and remapped stubs. + beginPhase(4, "lua native lowering"); + LuaNativeLowering.transform(imProg); + timeTaker.endPhase(); + // inliner stage = 5; if (runArgs.isInline()) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/MapRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/MapRequest.java index 49c208124..48b6b83a4 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/MapRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/MapRequest.java @@ -169,6 +169,7 @@ protected File compileMap(File projectFolder, WurstGui gui, Optional mapCo String compiledMapScript = sb.toString(); LuaTranslator.assertNoLeakedHashtableNativeCalls(compiledMapScript); + LuaTranslator.assertNoLeakedGetHandleIdCalls(compiledMapScript); File buildDir = getBuildDir(); File outFile = new File(buildDir, BUILD_COMPILED_LUA_NAME); Files.write(compiledMapScript.getBytes(Charsets.UTF_8), outFile); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java new file mode 100644 index 000000000..739a803cb --- /dev/null +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java @@ -0,0 +1,245 @@ +package de.peeeq.wurstscript.translation.imtranslation; + +import de.peeeq.wurstscript.WurstOperator; +import de.peeeq.wurstscript.jassIm.*; + +import java.util.*; + +/** + * IM-level lowering pass for the Lua backend, run before optimization so the + * optimizer can inline and eliminate the generated wrappers. + * + *

Three classes of WC3 BJ calls are transformed: + *

    + *
  1. GetHandleId – replaced 1:1 by {@code __wurst_GetHandleId}, whose Lua + * implementation uses a stable table counter instead of the WC3 handle ID + * (which can desync in Lua mode).
  2. + *
  3. Hashtable natives ({@code SaveInteger}, {@code LoadBoolean}, …) and + * context-callback natives ({@code ForForce}, {@code ForGroup}, …) – + * replaced 1:1 by their {@code __wurst_} prefixed equivalents, whose Lua + * implementations are provided by {@link de.peeeq.wurstscript.translation.lua.translation.LuaNatives}.
  4. + *
  5. All other BJ calls with at least one handle-typed parameter – wrapped + * by a generated IM function that first checks each handle param for {@code null} + * and returns the type-appropriate default (0 / 0.0 / false / "" / nil), then + * delegates to the original BJ function. This matches Jass behavior, which + * silently returns defaults on null-handle calls instead of crashing.
  6. + *
+ * + *

IS_NATIVE stubs added for category 1 and 2 are recognised by + * {@link de.peeeq.wurstscript.translation.lua.translation.LuaTranslator#translateFunc} as + * Wurst-owned natives and filled in by + * {@link de.peeeq.wurstscript.translation.lua.translation.LuaNatives}. + */ +public final class LuaNativeLowering { + + /** Hashtable native names that need to be remapped to {@code __wurst_} equivalents. */ + private static final Set HASHTABLE_NATIVE_NAMES = new HashSet<>(Arrays.asList( + "InitHashtable", + "SaveInteger", "SaveBoolean", "SaveReal", "SaveStr", + "LoadInteger", "LoadBoolean", "LoadReal", "LoadStr", + "HaveSavedInteger", "HaveSavedBoolean", "HaveSavedReal", "HaveSavedString", "HaveSavedHandle", + "FlushChildHashtable", "FlushParentHashtable", + "RemoveSavedInteger", "RemoveSavedBoolean", "RemoveSavedReal", "RemoveSavedString", "RemoveSavedHandle", + // Handle-typed save/load variants + "SavePlayerHandle", "SaveWidgetHandle", "SaveDestructableHandle", "SaveItemHandle", "SaveUnitHandle", + "SaveAbilityHandle", "SaveTimerHandle", "SaveTriggerHandle", "SaveTriggerConditionHandle", + "SaveTriggerActionHandle", "SaveTriggerEventHandle", "SaveForceHandle", "SaveGroupHandle", + "SaveLocationHandle", "SaveRectHandle", "SaveBooleanExprHandle", "SaveSoundHandle", "SaveEffectHandle", + "SaveUnitPoolHandle", "SaveItemPoolHandle", "SaveQuestHandle", "SaveQuestItemHandle", + "SaveDefeatConditionHandle", "SaveTimerDialogHandle", "SaveLeaderboardHandle", "SaveMultiboardHandle", + "SaveMultiboardItemHandle", "SaveTrackableHandle", "SaveDialogHandle", "SaveButtonHandle", + "SaveTextTagHandle", "SaveLightningHandle", "SaveImageHandle", "SaveUbersplatHandle", "SaveRegionHandle", + "SaveFogStateHandle", "SaveFogModifierHandle", "SaveAgentHandle", "SaveHashtableHandle", "SaveFrameHandle", + "LoadPlayerHandle", "LoadWidgetHandle", "LoadDestructableHandle", "LoadItemHandle", "LoadUnitHandle", + "LoadAbilityHandle", "LoadTimerHandle", "LoadTriggerHandle", "LoadTriggerConditionHandle", + "LoadTriggerActionHandle", "LoadTriggerEventHandle", "LoadForceHandle", "LoadGroupHandle", + "LoadLocationHandle", "LoadRectHandle", "LoadBooleanExprHandle", "LoadSoundHandle", "LoadEffectHandle", + "LoadUnitPoolHandle", "LoadItemPoolHandle", "LoadQuestHandle", "LoadQuestItemHandle", + "LoadDefeatConditionHandle", "LoadTimerDialogHandle", "LoadLeaderboardHandle", "LoadMultiboardHandle", + "LoadMultiboardItemHandle", "LoadTrackableHandle", "LoadDialogHandle", "LoadButtonHandle", + "LoadTextTagHandle", "LoadLightningHandle", "LoadImageHandle", "LoadUbersplatHandle", "LoadRegionHandle", + "LoadFogStateHandle", "LoadFogModifierHandle", "LoadHashtableHandle", "LoadFrameHandle" + )); + + /** Context-callback natives that need to be remapped to {@code __wurst_} equivalents. */ + private static final Set CONTEXT_CALLBACK_NATIVE_NAMES = new HashSet<>(Arrays.asList( + "ForForce", "GetEnumPlayer", + "ForGroup", "GetEnumUnit", + "EnumItemsInRect", "GetEnumItem", + "EnumDestructablesInRect", "GetEnumDestructable" + )); + + private LuaNativeLowering() {} + + /** + * Transforms the IM program in place. + * + *

Must be called before the optimizer so that the optimizer + * can inline and eliminate the generated wrappers. + */ + public static void transform(ImProg prog) { + // Maps original BJ function → replacement (either a IS_NATIVE stub or a nil-safety wrapper) + Map replacements = new LinkedHashMap<>(); + // Nil-safety wrappers are collected separately and added to prog AFTER the traversal, + // so the traversal does not visit their bodies and replace their internal BJ delegate calls. + List deferredWrappers = new ArrayList<>(); + + // Snapshot to avoid ConcurrentModificationException when createNativeStub adds to prog.getFunctions() + List snapshot = new ArrayList<>(prog.getFunctions()); + for (ImFunction f : snapshot) { + if (!f.isBj()) { + continue; + } + String name = f.getName(); + + if ("GetHandleId".equals(name)) { + replacements.put(f, createNativeStub("__wurst_GetHandleId", f, prog)); + } else if (HASHTABLE_NATIVE_NAMES.contains(name)) { + replacements.put(f, createNativeStub("__wurst_" + name, f, prog)); + } else if (CONTEXT_CALLBACK_NATIVE_NAMES.contains(name)) { + replacements.put(f, createNativeStub("__wurst_" + name, f, prog)); + } else if (hasHandleParam(f)) { + ImFunction wrapper = createNilSafeWrapper(f); + replacements.put(f, wrapper); + deferredWrappers.add(wrapper); + } + } + + if (replacements.isEmpty()) { + return; + } + + // Replace all call sites in the existing IM (before adding wrappers). + // Wrappers are deferred so their internal BJ delegate calls are not replaced. + prog.accept(new Element.DefaultVisitor() { + @Override + public void visit(ImFunctionCall call) { + super.visit(call); + ImFunction replacement = replacements.get(call.getFunc()); + if (replacement != null) { + call.replaceBy(JassIm.ImFunctionCall( + call.attrTrace(), replacement, + JassIm.ImTypeArguments(), + call.getArguments().copy(), + false, CallType.NORMAL)); + } + } + }); + + // Add nil-safety wrapper functions AFTER traversal so their own bodies are not traversed. + prog.getFunctions().addAll(deferredWrappers); + } + + /** + * Creates a new IS_NATIVE (non-BJ) IM function stub with the same signature as + * {@code original}. The Lua translator will fill in the body via + * {@code LuaNatives.get()} when it encounters the stub. + */ + private static ImFunction createNativeStub(String name, ImFunction original, ImProg prog) { + ImVars params = JassIm.ImVars(); + for (ImVar p : original.getParameters()) { + params.add(JassIm.ImVar(p.attrTrace(), p.getType().copy(), p.getName(), false)); + } + ImFunction stub = JassIm.ImFunction( + original.attrTrace(), name, + JassIm.ImTypeVars(), params, + original.getReturnType().copy(), + JassIm.ImVars(), JassIm.ImStmts(), + Collections.singletonList(FunctionFlagEnum.IS_NATIVE)); + prog.getFunctions().add(stub); + return stub; + } + + /** + * Creates a nil-safety wrapper for {@code bjNative}. + * + *

The generated function checks each handle-typed parameter against + * {@code null} and returns the type-appropriate default value if any is + * null. Otherwise it delegates to the original BJ function. + */ + private static ImFunction createNilSafeWrapper(ImFunction bjNative) { + ImVars params = JassIm.ImVars(); + List paramVars = new ArrayList<>(); + for (ImVar p : bjNative.getParameters()) { + ImVar copy = JassIm.ImVar(p.attrTrace(), p.getType().copy(), p.getName(), false); + params.add(copy); + paramVars.add(copy); + } + + ImStmts body = JassIm.ImStmts(); + + // Null-check each handle param: if param == null then return end + ImExpr returnDefault = defaultValueExpr(bjNative.getReturnType()); + for (ImVar param : paramVars) { + if (isHandleType(param.getType())) { + ImExpr condition = JassIm.ImOperatorCall(WurstOperator.EQ, JassIm.ImExprs( + JassIm.ImVarAccess(param), + JassIm.ImNull(param.getType().copy()) + )); + ImStmts thenBlock = JassIm.ImStmts( + JassIm.ImReturn(bjNative.attrTrace(), returnDefault.copy()) + ); + body.add(JassIm.ImIf(bjNative.attrTrace(), condition, thenBlock, JassIm.ImStmts())); + } + } + + // Delegate to the original BJ native + ImExprs callArgs = JassIm.ImExprs(); + for (ImVar pv : paramVars) { + callArgs.add(JassIm.ImVarAccess(pv)); + } + ImFunctionCall delegate = JassIm.ImFunctionCall( + bjNative.attrTrace(), bjNative, + JassIm.ImTypeArguments(), callArgs, false, CallType.NORMAL); + + if (bjNative.getReturnType() instanceof ImVoid) { + body.add(delegate); + } else { + body.add(JassIm.ImReturn(bjNative.attrTrace(), delegate)); + } + + return JassIm.ImFunction( + bjNative.attrTrace(), + "__wurst_safe_" + bjNative.getName(), + JassIm.ImTypeVars(), params, + bjNative.getReturnType().copy(), + JassIm.ImVars(), body, + Collections.emptyList()); + } + + private static boolean hasHandleParam(ImFunction f) { + for (ImVar p : f.getParameters()) { + if (isHandleType(p.getType())) { + return true; + } + } + return false; + } + + /** Returns true for WC3 handle types (ImSimpleType that is not int/real/boolean/string). */ + static boolean isHandleType(ImType type) { + if (!(type instanceof ImSimpleType)) { + return false; + } + String n = ((ImSimpleType) type).getTypename(); + return !n.equals("integer") && !n.equals("real") && !n.equals("boolean") && !n.equals("string"); + } + + /** Returns an IM expression representing the safe default for the given return type. */ + private static ImExpr defaultValueExpr(ImType returnType) { + if (returnType instanceof ImSimpleType) { + String n = ((ImSimpleType) returnType).getTypename(); + switch (n) { + case "integer": return JassIm.ImIntVal(0); + case "real": return JassIm.ImRealVal("0.0"); + case "boolean": return JassIm.ImBoolVal(false); + case "string": return JassIm.ImStringVal(""); + } + } + // void or handle type → null + if (returnType instanceof ImVoid) { + return JassIm.ImNull(JassIm.ImVoid()); + } + return JassIm.ImNull(returnType.copy()); + } +} diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaAssertions.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaAssertions.java new file mode 100644 index 000000000..23c6aa1e2 --- /dev/null +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaAssertions.java @@ -0,0 +1,139 @@ +package de.peeeq.wurstscript.translation.lua.translation; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Static assertion helpers for the Lua backend. + * + * These are called after Lua code has been emitted to catch backend invariant + * violations early (e.g. leaked raw native calls that should have been rewritten). + */ +public class LuaAssertions { + + private LuaAssertions() {} + + /** + * Asserts that {@code luaCode} contains no raw call to {@code GetHandleId}. + * + * In Lua mode handle IDs can desync, so all uses of {@code GetHandleId} must + * be rewritten to {@code __wurst_GetHandleId} which uses a stable table-based + * counter instead. + */ + public static void assertNoLeakedGetHandleIdCalls(String luaCode) { + Set called = collectCalledFunctionNames(luaCode); + if (called.contains("GetHandleId")) { + throw new RuntimeException( + "Wurst Lua backend assertion failed: raw GetHandleId() call found in generated Lua. " + + "Use the __wurst_GetHandleId polyfill (table-based) instead to avoid desync."); + } + } + + /** + * Asserts that {@code luaCode} contains no raw call to any of the Jass hashtable + * natives (SaveInteger, LoadBoolean, …) that should have been rewritten to their + * {@code __wurst_} prefixed counterparts, and that every {@code __wurst_} hashtable + * helper that is called is also defined in the output. + */ + public static void assertNoLeakedHashtableNativeCalls(String luaCode) { + List leaked = new ArrayList<>(); + List missingHelpers = new ArrayList<>(); + Set calledFunctionNames = collectCalledFunctionNames(luaCode); + Set definedFunctionNames = collectDefinedFunctionNames(luaCode); + for (String nativeName : LuaTranslator.allHashtableNativeNames()) { + if (calledFunctionNames.contains(nativeName)) { + leaked.add(nativeName); + } + String helperName = "__wurst_" + nativeName; + boolean helperCalled = calledFunctionNames.contains(helperName); + boolean helperDefined = definedFunctionNames.contains(helperName); + if (helperCalled && !helperDefined) { + missingHelpers.add(helperName); + } + } + if (!leaked.isEmpty()) { + throw new RuntimeException("Wurst Lua backend assertion failed: leaked raw hashtable native calls in generated Lua: " + + String.join(", ", leaked)); + } + if (!missingHelpers.isEmpty()) { + throw new RuntimeException("Wurst Lua backend assertion failed: missing __wurst hashtable helper definitions in generated Lua: " + + String.join(", ", missingHelpers)); + } + } + + static Set collectCalledFunctionNames(String text) { + Set result = new HashSet<>(); + int length = text.length(); + int index = 0; + while (index < length) { + if (!isIdentifierStart(text.charAt(index))) { + index++; + continue; + } + int end = scanIdentifierEnd(text, index + 1); + int next = skipWhitespace(text, end); + if (next < length && text.charAt(next) == '(') { + result.add(text.substring(index, end)); + } + index = end; + } + return result; + } + + static Set collectDefinedFunctionNames(String text) { + Set result = new HashSet<>(); + int length = text.length(); + int index = 0; + while (index < length) { + if (!matchesWord(text, index, "function")) { + index++; + continue; + } + int nameStart = skipWhitespace(text, index + "function".length()); + if (nameStart >= length || !isIdentifierStart(text.charAt(nameStart))) { + index++; + continue; + } + int nameEnd = scanIdentifierEnd(text, nameStart + 1); + int next = skipWhitespace(text, nameEnd); + if (next < length && text.charAt(next) == '(') { + result.add(text.substring(nameStart, nameEnd)); + } + index = nameEnd; + } + return result; + } + + private static int skipWhitespace(String text, int index) { + while (index < text.length() && Character.isWhitespace(text.charAt(index))) { + index++; + } + return index; + } + + private static int scanIdentifierEnd(String text, int index) { + while (index < text.length() && isIdentifierPart(text.charAt(index))) { + index++; + } + return index; + } + + private static boolean matchesWord(String text, int index, String word) { + int end = index + word.length(); + if (end > text.length() || !text.regionMatches(index, word, 0, word.length())) { + return false; + } + return (index == 0 || !isIdentifierPart(text.charAt(index - 1))) + && (end == text.length() || !isIdentifierPart(text.charAt(end))); + } + + private static boolean isIdentifierStart(char ch) { + return ch == '_' || Character.isLetter(ch); + } + + private static boolean isIdentifierPart(char ch) { + return ch == '_' || Character.isLetterOrDigit(ch); + } +} diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaNatives.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaNatives.java index c9c97ab0a..5f4b16e0e 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaNatives.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaNatives.java @@ -427,6 +427,11 @@ public class LuaNatives { f.getBody().add(LuaAst.LuaLiteral("if t ~= nil and t[p] then t[p][c] = nil end")); }); + addNative("__wurst_GetHandleId", f -> { + f.getParams().add(LuaAst.LuaVariable("h", LuaAst.LuaNoExpr())); + f.getBody().add(LuaAst.LuaLiteral("return __wurst_objectToIndex(h)")); + }); + addNative("typeIdToTypeName", f -> { f.getParams().add(LuaAst.LuaVariable("typeId", LuaAst.LuaNoExpr())); f.getBody().add(LuaAst.LuaLiteral("return \"\"")); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaPolyfillSetup.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaPolyfillSetup.java new file mode 100644 index 000000000..f007f135c --- /dev/null +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaPolyfillSetup.java @@ -0,0 +1,221 @@ +package de.peeeq.wurstscript.translation.lua.translation; + +import de.peeeq.wurstscript.luaAst.*; + +import static de.peeeq.wurstscript.translation.lua.translation.ExprTranslation.WURST_SUPERTYPES; + +/** + * Builds and registers the Wurst Lua infrastructure functions that are always + * emitted into the generated script, regardless of what user code looks like. + * + * These include object/string index maps, ensure-type coercers, array defaults, + * hashtable helpers, and context-callback wrappers. + * + * All methods are static and take the active {@link LuaTranslator} as the first + * argument, following the same convention as {@link ExprTranslation}. + */ +class LuaPolyfillSetup { + + private LuaPolyfillSetup() {} + + static void createArrayInitFunction(LuaTranslator tr) { + String[] code = { + "local t = {}", + "local mt = {__index = function (table, key)", + " local v = d()", + " table[key] = v", + " return v", + "end}", + "setmetatable(t, mt)", + "return t" + }; + + tr.arrayInitFunction.getParams().add(LuaAst.LuaVariable("d", LuaAst.LuaNoExpr())); + for (String c : code) { + tr.arrayInitFunction.getBody().add(LuaAst.LuaLiteral(c)); + } + tr.luaModel.add(tr.arrayInitFunction); + } + + static void createStringConcatFunction(LuaTranslator tr) { + String[] code = { + "if x then", + " if y then return x .. y else return x end", + "else", + " return y", + "end" + }; + + tr.stringConcatFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + tr.stringConcatFunction.getParams().add(LuaAst.LuaVariable("y", LuaAst.LuaNoExpr())); + for (String c : code) { + tr.stringConcatFunction.getBody().add(LuaAst.LuaLiteral(c)); + } + tr.luaModel.add(tr.stringConcatFunction); + } + + static void createInstanceOfFunction(LuaTranslator tr) { + String[] code = { + "return x ~= nil and x." + WURST_SUPERTYPES + "[A]" + }; + + tr.instanceOfFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + tr.instanceOfFunction.getParams().add(LuaAst.LuaVariable("A", LuaAst.LuaNoExpr())); + for (String c : code) { + tr.instanceOfFunction.getBody().add(LuaAst.LuaLiteral(c)); + } + tr.luaModel.add(tr.instanceOfFunction); + } + + static void createObjectIndexFunctions(LuaTranslator tr) { + String vName = "__wurst_objectIndexMap"; + LuaVariable v = LuaAst.LuaVariable(vName, LuaAst.LuaExprNull()); + tr.luaModel.add(v); + tr.deferMainInit(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(v), LuaAst.LuaTableConstructor(LuaAst.LuaTableFields( + LuaAst.LuaTableNamedField("counter", LuaAst.LuaExprIntVal("0")) + )))); + + LuaVariable im = LuaAst.LuaVariable("__wurst_number_wrapper_map", LuaAst.LuaExprNull()); + tr.luaModel.add(im); + tr.deferMainInit(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(im), LuaAst.LuaTableConstructor(LuaAst.LuaTableFields( + LuaAst.LuaTableNamedField("counter", LuaAst.LuaExprIntVal("0")) + )))); + + { + String[] code = { + "if x == nil then", + " return 0", + "end", + "if type(x) == \"number\" then", + " if __wurst_number_wrapper_map[x] then", + " x = __wurst_number_wrapper_map[x]", + " else", + " local obj = {__wurst_boxed_number = x}", + " __wurst_number_wrapper_map[x] = obj", + " x = obj", + " end", + "end", + "if __wurst_objectIndexMap[x] then", + " return __wurst_objectIndexMap[x]", + "else", + " local r = __wurst_objectIndexMap.counter + 1", + " __wurst_objectIndexMap.counter = r", + " __wurst_objectIndexMap[r] = x", + " __wurst_objectIndexMap[x] = r", + " return r", + "end" + }; + + tr.toIndexFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + for (String c : code) { + tr.toIndexFunction.getBody().add(LuaAst.LuaLiteral(c)); + } + tr.luaModel.add(tr.toIndexFunction); + } + + { + String[] code = { + "if type(x) == \"number\" then", + " x = __wurst_objectIndexMap[x]", + "end", + "if type(x) == \"table\" and x.__wurst_boxed_number then", + " return x.__wurst_boxed_number", + "end", + "return x" + }; + + tr.fromIndexFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + for (String c : code) { + tr.fromIndexFunction.getBody().add(LuaAst.LuaLiteral(c)); + } + tr.luaModel.add(tr.fromIndexFunction); + } + } + + static void createStringIndexFunctions(LuaTranslator tr) { + LuaVariable map = LuaAst.LuaVariable("__wurst_string_index_map", LuaAst.LuaExprNull()); + tr.luaModel.add(map); + tr.deferMainInit(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(map), LuaAst.LuaTableConstructor(LuaAst.LuaTableFields( + LuaAst.LuaTableNamedField("counter", LuaAst.LuaExprIntVal("0")), + LuaAst.LuaTableNamedField("byString", LuaAst.LuaTableConstructor(LuaAst.LuaTableFields())), + LuaAst.LuaTableNamedField("byIndex", LuaAst.LuaTableConstructor(LuaAst.LuaTableFields())) + )))); + + { + String[] code = { + "if x == nil then", + " return 0", + "end", + "if type(x) ~= \"string\" then", + " x = tostring(x)", + "end", + "local id = __wurst_string_index_map.byString[x]", + "if id ~= nil then", + " return id", + "end", + "id = __wurst_string_index_map.counter + 1", + "__wurst_string_index_map.counter = id", + "__wurst_string_index_map.byString[x] = id", + "__wurst_string_index_map.byIndex[id] = x", + "return id" + }; + + tr.stringToIndexFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + for (String c : code) { + tr.stringToIndexFunction.getBody().add(LuaAst.LuaLiteral(c)); + } + tr.luaModel.add(tr.stringToIndexFunction); + } + + { + String[] code = { + "local id = tonumber(x)", + "if id == nil then", + " return \"\"", + "end", + "id = math.tointeger(id)", + "if id == nil then", + " return \"\"", + "end", + "local s = __wurst_string_index_map.byIndex[id]", + "if s == nil then", + " return \"\"", + "end", + "return s" + }; + + tr.stringFromIndexFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + for (String c : code) { + tr.stringFromIndexFunction.getBody().add(LuaAst.LuaLiteral(c)); + } + tr.luaModel.add(tr.stringFromIndexFunction); + } + } + + static void createEnsureTypeFunctions(LuaTranslator tr) { + tr.ensureIntFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + tr.ensureIntFunction.getBody().add(LuaAst.LuaLiteral("local n = tonumber(x)")); + tr.ensureIntFunction.getBody().add(LuaAst.LuaLiteral("if n == nil then return 0 end")); + tr.ensureIntFunction.getBody().add(LuaAst.LuaLiteral("local i = math.tointeger(n)")); + tr.ensureIntFunction.getBody().add(LuaAst.LuaLiteral("if i == nil then return 0 end")); + tr.ensureIntFunction.getBody().add(LuaAst.LuaLiteral("return i")); + tr.luaModel.add(tr.ensureIntFunction); + + tr.ensureBoolFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + tr.ensureBoolFunction.getBody().add(LuaAst.LuaLiteral("if x == nil then return false end")); + tr.ensureBoolFunction.getBody().add(LuaAst.LuaLiteral("return x")); + tr.luaModel.add(tr.ensureBoolFunction); + + tr.ensureRealFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + tr.ensureRealFunction.getBody().add(LuaAst.LuaLiteral("local n = tonumber(x)")); + tr.ensureRealFunction.getBody().add(LuaAst.LuaLiteral("if n == nil then return 0.0 end")); + tr.ensureRealFunction.getBody().add(LuaAst.LuaLiteral("return n")); + tr.luaModel.add(tr.ensureRealFunction); + + tr.ensureStrFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); + tr.ensureStrFunction.getBody().add(LuaAst.LuaLiteral("if x == nil then return \"\" end")); + tr.ensureStrFunction.getBody().add(LuaAst.LuaLiteral("return tostring(x)")); + tr.luaModel.add(tr.ensureStrFunction); + } + +} diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java index 5ca6996af..e19746a7c 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java @@ -52,25 +52,6 @@ public class LuaTranslator { "FlushChildHashtable", "FlushParentHashtable", "RemoveSavedInteger", "RemoveSavedBoolean", "RemoveSavedReal", "RemoveSavedString", "RemoveSavedHandle" ); - private static final List REQUIRED_WURST_HASHTABLE_HELPERS = Arrays.asList( - "__wurst_InitHashtable", - "__wurst_SaveInteger", "__wurst_SaveBoolean", "__wurst_SaveReal", "__wurst_SaveStr", - "__wurst_LoadInteger", "__wurst_LoadBoolean", "__wurst_LoadReal", "__wurst_LoadStr", - "__wurst_HaveSavedInteger", "__wurst_HaveSavedBoolean", "__wurst_HaveSavedReal", "__wurst_HaveSavedString", "__wurst_HaveSavedHandle", - "__wurst_FlushChildHashtable", "__wurst_FlushParentHashtable", - "__wurst_RemoveSavedInteger", "__wurst_RemoveSavedBoolean", "__wurst_RemoveSavedReal", "__wurst_RemoveSavedString", "__wurst_RemoveSavedHandle" - ); - private static final List REQUIRED_WURST_CONTEXT_CALLBACK_HELPERS = Arrays.asList( - "__wurst_ForForce", - "__wurst_GetEnumPlayer", - "__wurst_ForGroup", - "__wurst_GetEnumUnit", - "__wurst_EnumItemsInRect", - "__wurst_GetEnumItem", - "__wurst_EnumDestructablesInRect", - "__wurst_GetEnumDestructable" - ); - private static final Set HASHTABLE_NATIVE_NAMES = new HashSet<>(allHashtableNativeNames()); private static final Set LUA_HANDLE_TO_INDEX = Set.of( "widgetToIndex", "unitToIndex", "destructableToIndex", "itemToIndex", "abilityToIndex", "forceToIndex", "groupToIndex", "triggerToIndex", "triggeractionToIndex", "triggerconditionToIndex", @@ -130,7 +111,6 @@ private ImProg getProg() { List tupleEqualsFuncs = new ArrayList<>(); List tupleCopyFuncs = new ArrayList<>(); - GetAForB luaVar = new GetAForB() { @Override public LuaVariable initFor(ImVar a) { @@ -146,7 +126,7 @@ public LuaVariable initFor(ImVar a) { @Override public LuaFunction initFor(ImFunction a) { - String name = remapNativeName(a.getName()); + String name = a.getName(); if (!a.isExtern() && !a.isBj() && !a.isNative() && !isFixedEntryPoint(a)) { name = uniqueName(name); } else if (isFixedEntryPoint(a)) { @@ -237,37 +217,6 @@ public LuaTranslator(ImProg prog, ImTranslator imTr) { luaModel = LuaAst.LuaCompilationUnit(); } - private String remapNativeName(String name) { - if ("ForForce".equals(name)) { - return "__wurst_ForForce"; - } - if ("GetEnumPlayer".equals(name)) { - return "__wurst_GetEnumPlayer"; - } - if ("ForGroup".equals(name)) { - return "__wurst_ForGroup"; - } - if ("GetEnumUnit".equals(name)) { - return "__wurst_GetEnumUnit"; - } - if ("EnumItemsInRect".equals(name)) { - return "__wurst_EnumItemsInRect"; - } - if ("GetEnumItem".equals(name)) { - return "__wurst_GetEnumItem"; - } - if ("EnumDestructablesInRect".equals(name)) { - return "__wurst_EnumDestructablesInRect"; - } - if ("GetEnumDestructable".equals(name)) { - return "__wurst_GetEnumDestructable"; - } - if (HASHTABLE_NATIVE_NAMES.contains(name)) { - return "__wurst_" + name; - } - return name; - } - protected String uniqueName(String name) { Integer nextIndex = uniqueNameCounters.get(name); if (nextIndex == null) { @@ -299,8 +248,6 @@ public LuaCompilationUnit translate() { createObjectIndexFunctions(); createStringIndexFunctions(); createEnsureTypeFunctions(); - ensureWurstHashtableHelpers(); - ensureWurstContextCallbackHelpers(); for (ImVar v : prog.getGlobals()) { translateGlobal(v); @@ -331,31 +278,7 @@ public LuaCompilationUnit translate() { return luaModel; } - /** - * Always emit internal hashtable helper functions used by Lua lowering. - * This keeps compiletime migration data loading robust even if the - * corresponding Warcraft natives are unavailable or filtered out. - */ - private void ensureWurstHashtableHelpers() { - Set requiredHelpers = new LinkedHashSet<>(REQUIRED_WURST_HASHTABLE_HELPERS); - requiredHelpers.addAll(prefixed(HASHTABLE_HANDLE_SAVE_NAMES)); - requiredHelpers.addAll(prefixed(HASHTABLE_HANDLE_LOAD_NAMES)); - for (String helper : requiredHelpers) { - LuaFunction f = LuaAst.LuaFunction(helper, LuaAst.LuaParams(), LuaAst.LuaStatements()); - LuaNatives.get(f); - luaModel.add(f); - } - } - - private void ensureWurstContextCallbackHelpers() { - for (String helper : REQUIRED_WURST_CONTEXT_CALLBACK_HELPERS) { - LuaFunction f = LuaAst.LuaFunction(helper, LuaAst.LuaParams(), LuaAst.LuaStatements()); - LuaNatives.get(f); - luaModel.add(f); - } - } - - private void deferMainInit(LuaStatement statement) { + void deferMainInit(LuaStatement statement) { deferredMainInit.add(statement); } @@ -375,121 +298,23 @@ private void prependDeferredMainInitToMain() { } } - public static void assertNoLeakedHashtableNativeCalls(String luaCode) { - List leaked = new ArrayList<>(); - List missingHelpers = new ArrayList<>(); - Set calledFunctionNames = collectCalledFunctionNames(luaCode); - Set definedFunctionNames = collectDefinedFunctionNames(luaCode); - for (String nativeName : allHashtableNativeNames()) { - if (calledFunctionNames.contains(nativeName)) { - leaked.add(nativeName); - } - String helperName = "__wurst_" + nativeName; - boolean helperCalled = calledFunctionNames.contains(helperName); - boolean helperDefined = definedFunctionNames.contains(helperName); - if (helperCalled && !helperDefined) { - missingHelpers.add(helperName); - } - } - if (!leaked.isEmpty()) { - throw new RuntimeException("Wurst Lua backend assertion failed: leaked raw hashtable native calls in generated Lua: " - + String.join(", ", leaked)); - } - if (!missingHelpers.isEmpty()) { - throw new RuntimeException("Wurst Lua backend assertion failed: missing __wurst hashtable helper definitions in generated Lua: " - + String.join(", ", missingHelpers)); - } - } - - private static Set collectCalledFunctionNames(String text) { - Set result = new HashSet<>(); - int length = text.length(); - int index = 0; - while (index < length) { - if (!isIdentifierStart(text.charAt(index))) { - index++; - continue; - } - int end = scanIdentifierEnd(text, index + 1); - int next = skipWhitespace(text, end); - if (next < length && text.charAt(next) == '(') { - result.add(text.substring(index, end)); - } - index = end; - } - return result; - } - - private static Set collectDefinedFunctionNames(String text) { - Set result = new HashSet<>(); - int length = text.length(); - int index = 0; - while (index < length) { - if (!matchesWord(text, index, "function")) { - index++; - continue; - } - int nameStart = skipWhitespace(text, index + "function".length()); - if (nameStart >= length || !isIdentifierStart(text.charAt(nameStart))) { - index++; - continue; - } - int nameEnd = scanIdentifierEnd(text, nameStart + 1); - int next = skipWhitespace(text, nameEnd); - if (next < length && text.charAt(next) == '(') { - result.add(text.substring(nameStart, nameEnd)); - } - index = nameEnd; - } - return result; - } - - private static int skipWhitespace(String text, int index) { - while (index < text.length() && Character.isWhitespace(text.charAt(index))) { - index++; - } - return index; + // Assertion helpers are implemented in LuaAssertions; kept here as public entry points + // for callers that reference LuaTranslator directly. + public static void assertNoLeakedGetHandleIdCalls(String luaCode) { + LuaAssertions.assertNoLeakedGetHandleIdCalls(luaCode); } - private static int scanIdentifierEnd(String text, int index) { - while (index < text.length() && isIdentifierPart(text.charAt(index))) { - index++; - } - return index; - } - - private static boolean matchesWord(String text, int index, String word) { - int end = index + word.length(); - if (end > text.length() || !text.regionMatches(index, word, 0, word.length())) { - return false; - } - return (index == 0 || !isIdentifierPart(text.charAt(index - 1))) - && (end == text.length() || !isIdentifierPart(text.charAt(end))); - } - - private static boolean isIdentifierStart(char ch) { - return ch == '_' || Character.isLetter(ch); - } - - private static boolean isIdentifierPart(char ch) { - return ch == '_' || Character.isLetterOrDigit(ch); + public static void assertNoLeakedHashtableNativeCalls(String luaCode) { + LuaAssertions.assertNoLeakedHashtableNativeCalls(luaCode); } - private static List allHashtableNativeNames() { + static List allHashtableNativeNames() { List result = new ArrayList<>(HASHTABLE_NATIVE_NAMES_RAW); result.addAll(HASHTABLE_HANDLE_SAVE_NAMES); result.addAll(HASHTABLE_HANDLE_LOAD_NAMES); return result; } - private static List prefixed(List names) { - List result = new ArrayList<>(); - for (String name : names) { - result.add("__wurst_" + name); - } - return result; - } - private boolean isFixedEntryPoint(ImFunction function) { return function == imTr.getMainFunc() || function == imTr.getConfFunc(); } @@ -497,7 +322,11 @@ private boolean isFixedEntryPoint(ImFunction function) { private void collectPredefinedNames() { for (ImFunction function : prog.getFunctions()) { if (function.isBj() || function.isExtern() || function.isNative()) { - setNameFromTrace(function); + // Don't rename Wurst-internal stubs (names starting with __wurst_) + // since their names are intentionally different from their trace's source name. + if (!function.getName().startsWith("__wurst_")) { + setNameFromTrace(function); + } usedNames.add(function.getName()); } } @@ -575,220 +404,27 @@ private void collectMethodNames(ImClass c, Set methodNames, Set } private void createStringConcatFunction() { - String[] code = { - "if x then", - " if y then return x .. y else return x end", - "else", - " return y", - "end" - }; - - stringConcatFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - stringConcatFunction.getParams().add(LuaAst.LuaVariable("y", LuaAst.LuaNoExpr())); - for (String c : code) { - stringConcatFunction.getBody().add(LuaAst.LuaLiteral(c)); - } - luaModel.add(stringConcatFunction); + LuaPolyfillSetup.createStringConcatFunction(this); } private void createInstanceOfFunction() { - // x instanceof A - - // ==> x ~= nil and x.__wurst_supertypes[A] - - String[] code = { - "return x ~= nil and x." + WURST_SUPERTYPES + "[A]" - }; - - instanceOfFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - instanceOfFunction.getParams().add(LuaAst.LuaVariable("A", LuaAst.LuaNoExpr())); - for (String c : code) { - instanceOfFunction.getBody().add(LuaAst.LuaLiteral(c)); - } - luaModel.add(instanceOfFunction); + LuaPolyfillSetup.createInstanceOfFunction(this); } private void createObjectIndexFunctions() { - String vName = "__wurst_objectIndexMap"; - LuaVariable v = LuaAst.LuaVariable(vName, LuaAst.LuaExprNull()); - luaModel.add(v); - deferMainInit(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(v), LuaAst.LuaTableConstructor(LuaAst.LuaTableFields( - LuaAst.LuaTableNamedField("counter", LuaAst.LuaExprIntVal("0")) - )))); - - LuaVariable im = LuaAst.LuaVariable("__wurst_number_wrapper_map", LuaAst.LuaExprNull()); - luaModel.add(im); - deferMainInit(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(im), LuaAst.LuaTableConstructor(LuaAst.LuaTableFields( - LuaAst.LuaTableNamedField("counter", LuaAst.LuaExprIntVal("0")) - )))); - - { - String[] code = { - "if x == nil then", - " return 0", - "end", - // wrap numbers in special number-objects: - "if type(x) == \"number\" then", - " if __wurst_number_wrapper_map[x] then", - " x = __wurst_number_wrapper_map[x]", - " else", - " local obj = {__wurst_boxed_number = x}", - " __wurst_number_wrapper_map[x] = obj", - " x = obj", - " end", - "end", - "if __wurst_objectIndexMap[x] then", - " return __wurst_objectIndexMap[x]", - "else", - " local r = __wurst_objectIndexMap.counter + 1", - " __wurst_objectIndexMap.counter = r", - " __wurst_objectIndexMap[r] = x", - " __wurst_objectIndexMap[x] = r", - " return r", - "end" - }; - - toIndexFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - for (String c : code) { - toIndexFunction.getBody().add(LuaAst.LuaLiteral(c)); - } - luaModel.add(toIndexFunction); - } - - { - String[] code = { - "if type(x) == \"number\" then", - " x = __wurst_objectIndexMap[x]", - "end", - "if type(x) == \"table\" and x.__wurst_boxed_number then", - " return x.__wurst_boxed_number", - "end", - "return x" - }; - - fromIndexFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - for (String c : code) { - fromIndexFunction.getBody().add(LuaAst.LuaLiteral(c)); - } - luaModel.add(fromIndexFunction); - } + LuaPolyfillSetup.createObjectIndexFunctions(this); } private void createStringIndexFunctions() { - LuaVariable map = LuaAst.LuaVariable("__wurst_string_index_map", LuaAst.LuaExprNull()); - luaModel.add(map); - deferMainInit(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(map), LuaAst.LuaTableConstructor(LuaAst.LuaTableFields( - LuaAst.LuaTableNamedField("counter", LuaAst.LuaExprIntVal("0")), - LuaAst.LuaTableNamedField("byString", LuaAst.LuaTableConstructor(LuaAst.LuaTableFields())), - LuaAst.LuaTableNamedField("byIndex", LuaAst.LuaTableConstructor(LuaAst.LuaTableFields())) - )))); - - { - String[] code = { - "if x == nil then", - " return 0", - "end", - "if type(x) ~= \"string\" then", - " x = tostring(x)", - "end", - "local id = __wurst_string_index_map.byString[x]", - "if id ~= nil then", - " return id", - "end", - "id = __wurst_string_index_map.counter + 1", - "__wurst_string_index_map.counter = id", - "__wurst_string_index_map.byString[x] = id", - "__wurst_string_index_map.byIndex[id] = x", - "return id" - }; - - stringToIndexFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - for (String c : code) { - stringToIndexFunction.getBody().add(LuaAst.LuaLiteral(c)); - } - luaModel.add(stringToIndexFunction); - } - - { - String[] code = { - "local id = tonumber(x)", - "if id == nil then", - " return \"\"", - "end", - "id = math.tointeger(id)", - "if id == nil then", - " return \"\"", - "end", - "local s = __wurst_string_index_map.byIndex[id]", - "if s == nil then", - " return \"\"", - "end", - "return s" - }; - - stringFromIndexFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - for (String c : code) { - stringFromIndexFunction.getBody().add(LuaAst.LuaLiteral(c)); - } - luaModel.add(stringFromIndexFunction); - } + LuaPolyfillSetup.createStringIndexFunctions(this); } private void createArrayInitFunction() { - /* - function defaultArray(d) - local t = {} - local mt = {__index = function (table, key) - local v = d() - table[key] = v - return v - end} - setmetatable(t, mt) - return t - end - */ - String[] code = { - "local t = {}", - "local mt = {__index = function (table, key)", - " local v = d()", - " table[key] = v", - " return v", - "end}", - "setmetatable(t, mt)", - "return t" - }; - - arrayInitFunction.getParams().add(LuaAst.LuaVariable("d", LuaAst.LuaNoExpr())); - for (String c : code) { - arrayInitFunction.getBody().add(LuaAst.LuaLiteral(c)); - } - luaModel.add(arrayInitFunction); + LuaPolyfillSetup.createArrayInitFunction(this); } private void createEnsureTypeFunctions() { - ensureIntFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - ensureIntFunction.getBody().add(LuaAst.LuaLiteral("local n = tonumber(x)")); - ensureIntFunction.getBody().add(LuaAst.LuaLiteral("if n == nil then return 0 end")); - ensureIntFunction.getBody().add(LuaAst.LuaLiteral("local i = math.tointeger(n)")); - ensureIntFunction.getBody().add(LuaAst.LuaLiteral("if i == nil then return 0 end")); - ensureIntFunction.getBody().add(LuaAst.LuaLiteral("return i")); - luaModel.add(ensureIntFunction); - - ensureBoolFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - ensureBoolFunction.getBody().add(LuaAst.LuaLiteral("if x == nil then return false end")); - ensureBoolFunction.getBody().add(LuaAst.LuaLiteral("return x")); - luaModel.add(ensureBoolFunction); - - ensureRealFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - ensureRealFunction.getBody().add(LuaAst.LuaLiteral("local n = tonumber(x)")); - ensureRealFunction.getBody().add(LuaAst.LuaLiteral("if n == nil then return 0.0 end")); - ensureRealFunction.getBody().add(LuaAst.LuaLiteral("return n")); - luaModel.add(ensureRealFunction); - - ensureStrFunction.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); - ensureStrFunction.getBody().add(LuaAst.LuaLiteral("if x == nil then return \"\" end")); - ensureStrFunction.getBody().add(LuaAst.LuaLiteral("return tostring(x)")); - luaModel.add(ensureStrFunction); + LuaPolyfillSetup.createEnsureTypeFunctions(this); } private void cleanStatements() { @@ -855,18 +491,23 @@ private void translateFunc(ImFunction f) { } if (f.isExtern() || f.isNative()) { - // only add the function if it is not yet defined: String name = lf.getName(); - luaModel.add(LuaAst.LuaIf( - LuaAst.LuaExprFuncRef(lf), - LuaAst.LuaStatements(), - LuaAst.LuaStatements( - LuaAst.LuaAssignment(LuaAst.LuaLiteral(name), LuaAst.LuaExprFunctionAbstraction( - lf.getParams().copy(), - lf.getBody().copy() - )) - ) - )); + if (name.startsWith("__wurst_")) { + // Wurst-internal natives are never pre-defined by the WC3 runtime; emit directly. + luaModel.add(lf); + } else { + // only add the function if it is not yet defined by the WC3 runtime: + luaModel.add(LuaAst.LuaIf( + LuaAst.LuaExprFuncRef(lf), + LuaAst.LuaStatements(), + LuaAst.LuaStatements( + LuaAst.LuaAssignment(LuaAst.LuaLiteral(name), LuaAst.LuaExprFunctionAbstraction( + lf.getParams().copy(), + lf.getBody().copy() + )) + ) + )); + } } else { luaModel.add(lf); } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java index 86084cdd6..0105819f5 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java @@ -1897,7 +1897,9 @@ public void stdLibDoesNotEmitWar3HashtableNatives() throws IOException { assertDoesNotContainRegex(compiled, "\\bRemoveSavedHandle\\("); assertDoesNotContainRegex(compiled, "\\bSaveAbilityHandle\\("); assertDoesNotContainRegex(compiled, "\\bLoadAbilityHandle\\("); - assertTrue(compiled.contains("function __wurst_HaveSavedHandle(")); + // With IM-level remapping, helpers are only emitted when called. + // SaveInteger is used by stdlib init code, so its helper must be present. + assertContainsRegex(compiled, "\\bfunction\\s+__wurst_SaveInteger\\s*\\("); } @Test @@ -2180,4 +2182,98 @@ public void subclassAllocationIncludesInheritedFieldsInLua() throws IOException "function\\s+[A-Za-z0-9_]+:create\\d+\\s*\\(\\)\\s*\\n\\s*local new_inst = \\(\\{[^\\n]*Window_anchorBottom="); } + // ----- GetHandleId remapping ----- + + @Test + public void getHandleIdIsRemappedToWurstPolyfillInLua() throws IOException { + // User code that calls GetHandleId(unit) must be rewritten to __wurst_GetHandleId(unit) + // so that the table-based counter is used instead of the desync-prone native handle ID. + // Use withStdLib so that 'unit'/'agent' types are known to the compiler. + test().testLua(true).withStdLib().lines( + "package Test", + "import MagicFunctions", + "init", + " let u = GetTriggerUnit()", + " let id = GetHandleId(u)", + " print(I2S(id))" + ); + String compiled = Files.toString(new File("test-output/lua/LuaTranslationTests_getHandleIdIsRemappedToWurstPolyfillInLua.lua"), Charsets.UTF_8); + // Raw GetHandleId must not appear as a call + assertDoesNotContainRegex(compiled, "\\bGetHandleId\\("); + // __wurst_GetHandleId must be defined + assertContainsRegex(compiled, "function\\s+__wurst_GetHandleId\\s*\\("); + // The polyfill must delegate to the object-index mechanism + assertContainsRegex(compiled, "__wurst_objectToIndex"); + // The Lua backend assertion should not throw + de.peeeq.wurstscript.translation.lua.translation.LuaTranslator.assertNoLeakedGetHandleIdCalls(compiled); + } + + @Test + public void getHandleIdAssertionDetectsLeak() { + // Verify the assertion helper throws when a raw GetHandleId call is present. + try { + de.peeeq.wurstscript.translation.lua.translation.LuaTranslator + .assertNoLeakedGetHandleIdCalls("GetHandleId(x)"); + fail("Expected RuntimeException for leaked GetHandleId call"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("GetHandleId")); + } + } + + // ----- Null-safe extern native wrappers ----- + + @Test + public void externNativesWithHandleParamsGetNilSafetyWrapper() throws IOException { + // BJ natives (IS_BJ) with handle-type parameters must be wrapped at the IM level + // so that passing nil returns a safe default instead of crashing the Lua runtime. + // GetUnitTypeId is a common.j native (IS_BJ) with a handle param, returns integer. + // Result is used in print() so the optimizer does not dead-code-eliminate the call. + test().testLua(true).withStdLib().lines( + "package Test", + "import MagicFunctions", + "init", + " let u = GetTriggerUnit()", + " print(I2S(GetUnitTypeId(u)))" + ); + String compiled = Files.toString(new File("test-output/lua/LuaTranslationTests_externNativesWithHandleParamsGetNilSafetyWrapper.lua"), Charsets.UTF_8); + // A nil-safety wrapper must be emitted for GetUnitTypeId (IS_BJ, handle → integer) + assertContainsRegex(compiled, "\\bfunction\\s+__wurst_safe_GetUnitTypeId\\s*\\("); + // Call site must be remapped to the wrapper + assertContainsRegex(compiled, "\\b__wurst_safe_GetUnitTypeId\\("); + // Return default for integer type must be 0 + assertContainsRegex(compiled, "return 0"); + } + + @Test + public void externNativesWithOnlyPrimitiveParamsDoNotGetWrapper() throws IOException { + // Natives that only take integer/real/boolean params cannot crash with nil, + // so no wrapper should be emitted for them. + String compiled = compileLuaWithRunArgs( + "LuaTranslationTests_externNativesWithOnlyPrimitiveParamsDoNotGetWrapper", + false, + "package Test", + "native Sin(real x) returns real", + "native I2R(int i) returns real", + "init", + " let s = Sin(1.0)", + " let r = I2R(1)" + ); + // No nil-safety wrapper should be emitted for purely-primitive natives + assertDoesNotContainRegex(compiled, "__wurst_safe_Sin"); + assertDoesNotContainRegex(compiled, "__wurst_safe_I2R"); + } + + @Test + public void wurstInternalNativesDoNotGetNilSafetyWrapper() throws IOException { + // Wurst-internal natives (__wurst_* names) must not be double-wrapped. + String compiled = compileLuaWithRunArgs( + "LuaTranslationTests_wurstInternalNativesDoNotGetNilSafetyWrapper", + false, + "package Test", + "native testSuccess()" + ); + // __wurst_* helpers must not have the nil-safety wrapper pattern applied + assertDoesNotContainRegex(compiled, "__wurst_safe___wurst_"); + } + } From 03a77ccf9e87530ed15f8fc212db64cc4339dbed Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 7 Apr 2026 11:57:35 +0200 Subject: [PATCH 2/6] Update LuaNativeLowering.java --- .../imtranslation/LuaNativeLowering.java | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java index 739a803cb..60c1a53c6 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java @@ -78,18 +78,31 @@ private LuaNativeLowering() {} * can inline and eliminate the generated wrappers. */ public static void transform(ImProg prog) { + // Pre-scan: find which BJ functions are actually called, so we only create stubs/wrappers + // for reachable functions. Creating wrappers for all BJ functions in the IM (common.j has + // hundreds of them) would be extremely memory-intensive. + Set calledBjFuncs = new LinkedHashSet<>(); + prog.accept(new Element.DefaultVisitor() { + @Override + public void visit(ImFunctionCall call) { + super.visit(call); + if (call.getFunc().isBj()) { + calledBjFuncs.add(call.getFunc()); + } + } + }); + + if (calledBjFuncs.isEmpty()) { + return; + } + // Maps original BJ function → replacement (either a IS_NATIVE stub or a nil-safety wrapper) Map replacements = new LinkedHashMap<>(); // Nil-safety wrappers are collected separately and added to prog AFTER the traversal, // so the traversal does not visit their bodies and replace their internal BJ delegate calls. List deferredWrappers = new ArrayList<>(); - // Snapshot to avoid ConcurrentModificationException when createNativeStub adds to prog.getFunctions() - List snapshot = new ArrayList<>(prog.getFunctions()); - for (ImFunction f : snapshot) { - if (!f.isBj()) { - continue; - } + for (ImFunction f : calledBjFuncs) { String name = f.getName(); if ("GetHandleId".equals(name)) { From 937183e9a4a384738ca93fd06f175773206800b6 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 7 Apr 2026 12:24:12 +0200 Subject: [PATCH 3/6] fixes --- .../imtranslation/LuaNativeLowering.java | 102 ++++---- .../lua/translation/LuaAssertions.java | 220 ++++++++++++++++-- .../tests/LuaTranslationTests.java | 14 ++ 3 files changed, 271 insertions(+), 65 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java index 60c1a53c6..9a73225ef 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java @@ -76,59 +76,44 @@ private LuaNativeLowering() {} * *

Must be called before the optimizer so that the optimizer * can inline and eliminate the generated wrappers. + * + *

Stubs and wrappers are created lazily (on first call-site encounter) and added + * to prog only after the traversal completes. This avoids the memory cost of + * creating wrappers for every BJ function in the IM (common.j declares hundreds of + * functions, most of which are unreachable in any given program). */ public static void transform(ImProg prog) { - // Pre-scan: find which BJ functions are actually called, so we only create stubs/wrappers - // for reachable functions. Creating wrappers for all BJ functions in the IM (common.j has - // hundreds of them) would be extremely memory-intensive. - Set calledBjFuncs = new LinkedHashSet<>(); - prog.accept(new Element.DefaultVisitor() { - @Override - public void visit(ImFunctionCall call) { - super.visit(call); - if (call.getFunc().isBj()) { - calledBjFuncs.add(call.getFunc()); - } - } - }); - - if (calledBjFuncs.isEmpty()) { - return; - } - - // Maps original BJ function → replacement (either a IS_NATIVE stub or a nil-safety wrapper) + // Maps original BJ function → replacement (IS_NATIVE stub or nil-safety wrapper). + // Populated lazily during the traversal. Map replacements = new LinkedHashMap<>(); - // Nil-safety wrappers are collected separately and added to prog AFTER the traversal, - // so the traversal does not visit their bodies and replace their internal BJ delegate calls. - List deferredWrappers = new ArrayList<>(); - - for (ImFunction f : calledBjFuncs) { - String name = f.getName(); + // BJ functions that don't need a replacement (not GetHandleId, not hashtable/callback, + // no handle params). Cached to avoid rechecking the same function at every call site. + Set noReplacement = new HashSet<>(); + // All generated functions (stubs and wrappers) are deferred until after the traversal: + // - Stubs: deferred so ConcurrentModificationException is avoided on prog.getFunctions() + // - Wrappers: deferred so the visitor doesn't see their internal BJ delegate calls and + // recursively wrap them, which would cause infinite wrapping. + List deferredAdditions = new ArrayList<>(); - if ("GetHandleId".equals(name)) { - replacements.put(f, createNativeStub("__wurst_GetHandleId", f, prog)); - } else if (HASHTABLE_NATIVE_NAMES.contains(name)) { - replacements.put(f, createNativeStub("__wurst_" + name, f, prog)); - } else if (CONTEXT_CALLBACK_NATIVE_NAMES.contains(name)) { - replacements.put(f, createNativeStub("__wurst_" + name, f, prog)); - } else if (hasHandleParam(f)) { - ImFunction wrapper = createNilSafeWrapper(f); - replacements.put(f, wrapper); - deferredWrappers.add(wrapper); - } - } - - if (replacements.isEmpty()) { - return; - } - - // Replace all call sites in the existing IM (before adding wrappers). - // Wrappers are deferred so their internal BJ delegate calls are not replaced. prog.accept(new Element.DefaultVisitor() { @Override public void visit(ImFunctionCall call) { super.visit(call); - ImFunction replacement = replacements.get(call.getFunc()); + ImFunction f = call.getFunc(); + if (!f.isBj()) return; + if (noReplacement.contains(f)) return; + + if (!replacements.containsKey(f)) { + ImFunction r = computeReplacement(f); + if (r != null) { + replacements.put(f, r); + deferredAdditions.add(r); + } else { + noReplacement.add(f); + } + } + ImFunction replacement = replacements.get(f); + if (replacement != null) { call.replaceBy(JassIm.ImFunctionCall( call.attrTrace(), replacement, @@ -137,30 +122,45 @@ public void visit(ImFunctionCall call) { false, CallType.NORMAL)); } } + + private ImFunction computeReplacement(ImFunction bj) { + String name = bj.getName(); + if ("GetHandleId".equals(name)) { + return createNativeStub("__wurst_GetHandleId", bj); + } else if (HASHTABLE_NATIVE_NAMES.contains(name)) { + return createNativeStub("__wurst_" + name, bj); + } else if (CONTEXT_CALLBACK_NATIVE_NAMES.contains(name)) { + return createNativeStub("__wurst_" + name, bj); + } else if (hasHandleParam(bj)) { + return createNilSafeWrapper(bj); + } + return null; + } }); - // Add nil-safety wrapper functions AFTER traversal so their own bodies are not traversed. - prog.getFunctions().addAll(deferredWrappers); + // Add all generated functions after the traversal so their bodies are not visited + // by the replacement visitor above. + prog.getFunctions().addAll(deferredAdditions); } /** * Creates a new IS_NATIVE (non-BJ) IM function stub with the same signature as * {@code original}. The Lua translator will fill in the body via * {@code LuaNatives.get()} when it encounters the stub. + * + *

The caller is responsible for adding the stub to prog.getFunctions(). */ - private static ImFunction createNativeStub(String name, ImFunction original, ImProg prog) { + private static ImFunction createNativeStub(String name, ImFunction original) { ImVars params = JassIm.ImVars(); for (ImVar p : original.getParameters()) { params.add(JassIm.ImVar(p.attrTrace(), p.getType().copy(), p.getName(), false)); } - ImFunction stub = JassIm.ImFunction( + return JassIm.ImFunction( original.attrTrace(), name, JassIm.ImTypeVars(), params, original.getReturnType().copy(), JassIm.ImVars(), JassIm.ImStmts(), Collections.singletonList(FunctionFlagEnum.IS_NATIVE)); - prog.getFunctions().add(stub); - return stub; } /** diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaAssertions.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaAssertions.java index 23c6aa1e2..13779b2a7 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaAssertions.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaAssertions.java @@ -63,49 +63,241 @@ public static void assertNoLeakedHashtableNativeCalls(String luaCode) { } } + /** + * Collects all function names that appear as CALLS in the Lua source. + * + * Skips string literals, comments, and function declaration names (including + * method-syntax declarations like {@code function Foo:bar()} or + * {@code function Foo.bar()}) to avoid false positives. + */ static Set collectCalledFunctionNames(String text) { Set result = new HashSet<>(); int length = text.length(); int index = 0; while (index < length) { - if (!isIdentifierStart(text.charAt(index))) { - index++; + char ch = text.charAt(index); + + // Skip Lua comments: -- short or --[[ long ]] + if (ch == '-' && index + 1 < length && text.charAt(index + 1) == '-') { + int longLevel = countLongBracketLevel(text, index + 2); + if (longLevel >= 0) { + index = skipLongString(text, index + 2, longLevel); + } else { + // Short comment: skip to end of line + while (index < length && text.charAt(index) != '\n') { + index++; + } + } continue; } - int end = scanIdentifierEnd(text, index + 1); - int next = skipWhitespace(text, end); - if (next < length && text.charAt(next) == '(') { - result.add(text.substring(index, end)); + + // Skip string literals: "..." or '...' + if (ch == '"' || ch == '\'') { + index = skipQuotedString(text, index, ch); + continue; + } + + // Skip long strings: [[...]] or [=[...]=] + if (ch == '[') { + int longLevel = countLongBracketLevel(text, index); + if (longLevel >= 0) { + index = skipLongString(text, index, longLevel); + continue; + } } - index = end; + + // Skip function declarations: after the 'function' keyword the name tokens + // (including A.B or A:B method syntax) are NOT calls. + if (matchesWord(text, index, "function")) { + index = skipFunctionDeclarationName(text, index + "function".length()); + continue; + } + + // Check identifier followed by '(' → function call + if (isIdentifierStart(ch)) { + int end = scanIdentifierEnd(text, index + 1); + int next = skipWhitespace(text, end); + if (next < length && text.charAt(next) == '(') { + result.add(text.substring(index, end)); + } + index = end; + continue; + } + + index++; } return result; } + /** + * Collects function names that appear as DEFINITIONS in the Lua source. + * + * Handles both simple ({@code function name(}) and method-syntax + * ({@code function A:name(} or {@code function A.name(}) declarations. + * Skips string literals and comments. + */ static Set collectDefinedFunctionNames(String text) { Set result = new HashSet<>(); int length = text.length(); int index = 0; while (index < length) { + char ch = text.charAt(index); + + // Skip comments + if (ch == '-' && index + 1 < length && text.charAt(index + 1) == '-') { + int longLevel = countLongBracketLevel(text, index + 2); + if (longLevel >= 0) { + index = skipLongString(text, index + 2, longLevel); + } else { + while (index < length && text.charAt(index) != '\n') { + index++; + } + } + continue; + } + + // Skip string literals + if (ch == '"' || ch == '\'') { + index = skipQuotedString(text, index, ch); + continue; + } + if (ch == '[') { + int longLevel = countLongBracketLevel(text, index); + if (longLevel >= 0) { + index = skipLongString(text, index, longLevel); + continue; + } + } + if (!matchesWord(text, index, "function")) { index++; continue; } - int nameStart = skipWhitespace(text, index + "function".length()); - if (nameStart >= length || !isIdentifierStart(text.charAt(nameStart))) { + + // Skip past 'function', then scan the name + int pos = skipWhitespace(text, index + "function".length()); + if (pos >= length || !isIdentifierStart(text.charAt(pos))) { index++; continue; } - int nameEnd = scanIdentifierEnd(text, nameStart + 1); - int next = skipWhitespace(text, nameEnd); - if (next < length && text.charAt(next) == '(') { - result.add(text.substring(nameStart, nameEnd)); + + // Walk A.B.C or A:B chains, keeping track of the last identifier + String lastName = null; + while (pos < length && isIdentifierStart(text.charAt(pos))) { + int nameEnd = scanIdentifierEnd(text, pos + 1); + lastName = text.substring(pos, nameEnd); + pos = nameEnd; + if (pos < length && (text.charAt(pos) == '.' || text.charAt(pos) == ':')) { + pos++; // consume '.' or ':' + } else { + break; + } + } + + int next = skipWhitespace(text, pos); + if (lastName != null && next < length && text.charAt(next) == '(') { + result.add(lastName); } - index = nameEnd; + index = pos; } return result; } + /** + * After the {@code function} keyword, skip past the declaration name + * (which may include {@code A.B} or {@code A:B} qualifiers) and return + * the position after the opening {@code (}. + * + * If there is no valid name, returns the position just after the keyword. + */ + private static int skipFunctionDeclarationName(String text, int index) { + int length = text.length(); + int pos = skipWhitespace(text, index); + + if (pos >= length || !isIdentifierStart(text.charAt(pos))) { + // Anonymous function: 'function(' — no name to skip + return pos; + } + + // Walk A.B.C or A:B chains + while (pos < length && isIdentifierStart(text.charAt(pos))) { + pos = scanIdentifierEnd(text, pos + 1); + if (pos < length && (text.charAt(pos) == '.' || text.charAt(pos) == ':')) { + pos++; // consume '.' or ':' + } else { + break; + } + } + + // Skip to just after '(' so the outer loop doesn't re-examine the '(' + pos = skipWhitespace(text, pos); + if (pos < length && text.charAt(pos) == '(') { + pos++; + } + return pos; + } + + /** + * Returns the long-bracket level of a {@code [=..=[} opener at {@code index}, + * or -1 if there is no valid long-bracket opener at that position. + */ + private static int countLongBracketLevel(String text, int index) { + int length = text.length(); + if (index >= length || text.charAt(index) != '[') { + return -1; + } + int level = 0; + int pos = index + 1; + while (pos < length && text.charAt(pos) == '=') { + level++; + pos++; + } + if (pos < length && text.charAt(pos) == '[') { + return level; + } + return -1; + } + + /** + * Skips past a long string starting with {@code [=..=[} at {@code index}. + * The {@code level} is the number of {@code =} signs in the bracket. + * Returns the index after the closing {@code ]=..=]}. + */ + private static int skipLongString(String text, int index, int level) { + int length = text.length(); + // Skip the opening bracket [=..=[ (1 + level + 1 chars) + int pos = index + 1 + level + 1; + String close = "]" + "=".repeat(level) + "]"; + int closeIdx = text.indexOf(close, pos); + if (closeIdx < 0) { + return length; + } + return closeIdx + close.length(); + } + + /** + * Skips a quoted string starting at {@code index} with quote character {@code quote}. + * Handles backslash escapes. Returns the index after the closing quote. + */ + private static int skipQuotedString(String text, int index, char quote) { + int length = text.length(); + int pos = index + 1; // skip opening quote + while (pos < length) { + char ch = text.charAt(pos); + if (ch == '\\') { + pos += 2; // skip escaped character + } else if (ch == quote) { + return pos + 1; + } else if (ch == '\n') { + // Unfinished string literal — treat as ended + return pos; + } else { + pos++; + } + } + return pos; + } + private static int skipWhitespace(String text, int index) { while (index < text.length() && Character.isWhitespace(text.charAt(index))) { index++; diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java index 0105819f5..859a30ae3 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java @@ -2220,6 +2220,20 @@ public void getHandleIdAssertionDetectsLeak() { } } + @Test + public void getHandleIdAssertionDoesNotFalsePositiveOnDeclarations() { + // A function declaration named GetHandleId (e.g. from a class method) must not + // trigger the assertion — only actual calls should. + de.peeeq.wurstscript.translation.lua.translation.LuaTranslator + .assertNoLeakedGetHandleIdCalls("function Foo:GetHandleId(x) return 0 end"); + de.peeeq.wurstscript.translation.lua.translation.LuaTranslator + .assertNoLeakedGetHandleIdCalls("function Foo.GetHandleId(x) return 0 end"); + de.peeeq.wurstscript.translation.lua.translation.LuaTranslator + .assertNoLeakedGetHandleIdCalls("-- GetHandleId(x) is remapped"); + de.peeeq.wurstscript.translation.lua.translation.LuaTranslator + .assertNoLeakedGetHandleIdCalls("local s = \"GetHandleId(x)\""); + } + // ----- Null-safe extern native wrappers ----- @Test From 3adc5cef26d274fd412412ded0897f6cb1f90943 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 7 Apr 2026 14:18:30 +0200 Subject: [PATCH 4/6] isLua magic constant --- .../de/peeeq/wurstscript/WurstKeywords.java | 2 +- .../imoptimizer/GlobalsInliner.java | 16 ++++++- .../imtranslation/LuaNativeLowering.java | 14 ++++++ .../lua/translation/ExprTranslation.java | 11 +++-- .../tests/LuaTranslationTests.java | 47 +++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstKeywords.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstKeywords.java index 182dbc91d..72935ff1a 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstKeywords.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstKeywords.java @@ -8,7 +8,7 @@ public class WurstKeywords { "tuple", "div", "mod", "let", "from", "to", "downto", "step", "endpackage", "skip", "true", "false", "var", "instanceof", "super", "enum", "switch", "case", "default", "typeId", "begin", "end", // not really a keyword, but it should feel like one: - "compiletime", + "compiletime", "isLua", // jurst keywords, maybe split the highlighters later...: "library", "endlibrary", "scope", "endscope", "requires", "uses", "needs", "struct", "endstruct", "then", "endif", "loop", "exitwhen", "endloop", "method", "takes", "endmethod", "set", "call", diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imoptimizer/GlobalsInliner.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imoptimizer/GlobalsInliner.java index bc83c1afd..b65abff35 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imoptimizer/GlobalsInliner.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imoptimizer/GlobalsInliner.java @@ -13,7 +13,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; public class GlobalsInliner implements OptimizerPass { public int optimize(ImTranslator trans) { @@ -28,6 +27,21 @@ public int optimize(ImTranslator trans) { // so it is important, that we do not optimize away the compiletime constant continue; } + if (v.getName().equals("MagicFunctions_isLua") && trans.isLuaTarget()) { + // In Lua mode, isLua must evaluate to true. + // Normal inlining would use the declared value (false); override it here. + for (ImVarRead read : new ArrayList<>(v.attrReads())) { + read.replaceBy(JassIm.ImBoolVal(true)); + } + for (ImVarWrite write : new ArrayList<>(v.attrWrites())) { + if (write.getParent() != null) { + write.replaceBy(ImHelper.nullExpr()); + } + } + obsoleteVars.add(v); + obsoleteCount++; + continue; + } if (v.getType() instanceof ImArrayType || v.getType() instanceof ImArrayTypeMulti) { // cannot optimize arrays yet diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java index 9a73225ef..1a13c46f8 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java @@ -83,6 +83,20 @@ private LuaNativeLowering() {} * functions, most of which are unreachable in any given program). */ public static void transform(ImProg prog) { + // Replace all reads of MagicFunctions_isLua with true. + // This must happen before any optimizer passes so that dead-code elimination + // can remove Jass-only branches at compile time. + // We use attrReads() (not a visitor) to target only rvalue uses, avoiding + // ClassCastException when the same ImVarAccess appears as a write target (lvalue). + for (ImVar global : prog.getGlobals()) { + if ("MagicFunctions_isLua".equals(global.getName())) { + for (ImVarRead read : new ArrayList<>(global.attrReads())) { + read.replaceBy(JassIm.ImBoolVal(true)); + } + break; + } + } + // Maps original BJ function → replacement (IS_NATIVE stub or nil-safety wrapper). // Populated lazily during the traversal. Map replacements = new LinkedHashMap<>(); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/ExprTranslation.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/ExprTranslation.java index 8ec95ee4f..876205779 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/ExprTranslation.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/ExprTranslation.java @@ -116,15 +116,20 @@ public static LuaExpr translate(ImFunctionCall e, LuaTranslator tr) { } LuaFunction f = tr.luaFunc.getFor(e.getFunc()); - if ("I2S".equals(f.getName()) && isIntentionalThreadAbortCall(e)) { + // Use the immutable ImFunction name rather than f.getName(), because f is a cached + // LuaFunction object shared across all call sites of this native. The setName() calls + // below mutate it, so f.getName() changes after the first translation and can no longer + // be relied upon for sentinel checks. + String imFuncName = e.getFunc().getName(); + if ("I2S".equals(imFuncName) && isIntentionalThreadAbortCall(e)) { return LuaAst.LuaExprFunctionCallByName("error", LuaAst.LuaExprlist( LuaAst.LuaExprStringVal(WURST_ABORT_THREAD_SENTINEL), LuaAst.LuaExprIntVal("0") )); } - if (f.getName().equals(ImTranslator.$DEBUG_PRINT)) { + if (ImTranslator.$DEBUG_PRINT.equals(imFuncName)) { f.setName("BJDebugMsg"); - } else if (f.getName().equals("I2S")) { + } else if ("I2S".equals(imFuncName)) { f.setName("tostring"); } return LuaAst.LuaExprFunctionCall(f, tr.translateExprList(e.getArguments())); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java index 859a30ae3..6cf645999 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java @@ -2124,6 +2124,31 @@ public void i2sDivisionByZeroCrashTrapUsesAbortSentinelInLua() throws IOExceptio assertDoesNotContainRegex(compiled, "error\\s*\\(\\s*\"[^\"]*divide by zero[^\"]*\""); } + @Test + public void i2sAbortSentinelNotBrokenByEarlierI2SCallInLua() throws IOException { + // Regression: ExprTranslation used f.setName("tostring") to rename the shared + // LuaFunction for I2S. After the first ordinary I2S(x) call was translated, f.getName() + // returned "tostring", so the sentinel check "I2S".equals(f.getName()) silently failed + // for any later I2S(1/0) abort call — leaving actual //0 division in the Lua output. + // This test forces a normal I2S call before the abort to expose the mutation bug. + test().testLua(true).withStdLib().lines( + "package Test", + "import MagicFunctions", + "import ErrorHandling", + "init", + " let x = 42", + " print(I2S(x))", // normal I2S call first — used to corrupt the cached LuaFunction + " error(\"test\")" // triggers I2S(1 div 0) inside ErrorHandling.error() + ); + String compiled = Files.toString(new File("test-output/lua/LuaTranslationTests_i2sAbortSentinelNotBrokenByEarlierI2SCallInLua.lua"), Charsets.UTF_8); + // The abort must be the sentinel, not a raw //0 that Lua would crash on at runtime + assertDoesNotContainRegex(compiled, "tostring\\s*\\(\\s*1\\s*//\\s*0\\s*\\)"); + assertDoesNotContainRegex(compiled, "tostring\\s*\\(\\s*1\\s*/\\s*0\\s*\\)"); + assertTrue(compiled.contains("__wurst_abort_thread")); + // tostring() must still be used for the normal I2S call + assertContainsRegex(compiled, "tostring\\s*\\("); + } + @Test public void luaErrorWrapperIgnoresAbortSentinel() throws IOException { test().testLua(true).withStdLib().lines( @@ -2290,4 +2315,26 @@ public void wurstInternalNativesDoNotGetNilSafetyWrapper() throws IOException { assertDoesNotContainRegex(compiled, "__wurst_safe___wurst_"); } + @Test + public void isLuaMagicConstantIsTrueInLuaMode() throws IOException { + // isLua must be inlined to true in Lua mode by GlobalsInliner, + // so backend-specific code paths can be selected at compile time. + test().testLua(true).withStdLib().lines( + "package Test", + "import MagicFunctions", + "init", + " if isLua", + " print(\"lua-path\")", + " else", + " print(\"jass-path\")" + ); + String compiled = Files.toString( + new File("test-output/lua/LuaTranslationTests_isLuaMagicConstantIsTrueInLuaMode.lua"), + Charsets.UTF_8); + // After inlining, the raw isLua variable must not appear in Lua output + assertFalse("MagicFunctions_isLua must be inlined away in Lua mode", compiled.contains("MagicFunctions_isLua")); + // The lua-path branch must be preserved + assertTrue("lua-path branch must be present", compiled.contains("lua-path")); + } + } From cbc62bc0fe8ade1b852e18fd153162d49104cf17 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 7 Apr 2026 22:57:02 +0200 Subject: [PATCH 5/6] fix build --- .../lua/translation/LuaTranslator.java | 37 ++----------------- .../tests/LuaTranslationTests.java | 28 +++++++++----- 2 files changed, 23 insertions(+), 42 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java index e19746a7c..b89bbc5f5 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java @@ -12,9 +12,6 @@ import de.peeeq.wurstscript.utils.Utils; import org.jetbrains.annotations.NotNull; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; import java.util.*; import java.util.stream.Stream; @@ -974,7 +971,7 @@ private int classDistance(ImClass from, ImClass to) { } private void debugDispatchGroup(ImClass receiverClass, String key, Set slotNames, List groupMethods, ImMethod chosen) { - if (!DEBUG_LUA_DISPATCH && !isSuspiciousGroup(slotNames, groupMethods, chosen)) { + if (!DEBUG_LUA_DISPATCH) { return; } String chosenImpl = chosen != null && chosen.getImplementation() != null ? chosen.getImplementation().getName() : "null"; @@ -988,38 +985,12 @@ private void debugDispatchGroup(ImClass receiverClass, String key, Set s } candidates.append(m.getName()).append("->").append(impl).append("@").append(classSortKey(m.attrClass())); } - System.err.println("[LuaDispatch] class=" + classSortKey(receiverClass) + String line = "[LuaDispatch] class=" + classSortKey(receiverClass) + " key=" + key + " slots=" + slotNames + " chosen=" + chosenImpl - + " candidates=[" + candidates + "]"); - if (DEBUG_LUA_DISPATCH) { - String line = "[LuaDispatch] class=" + classSortKey(receiverClass) - + " key=" + key - + " slots=" + slotNames - + " chosen=" + chosenImpl - + " candidates=[" + candidates + "]" - + System.lineSeparator(); - try { - Files.writeString(Path.of("C:/Users/Frotty/Documents/GitHub/WurstScript/lua-dispatch-debug.log"), - line, StandardOpenOption.CREATE, StandardOpenOption.APPEND); - } catch (Exception ignored) { - } - } - } - - private boolean isSuspiciousGroup(Set slotNames, List groupMethods, ImMethod chosen) { - if (slotNames.size() > 1) { - return true; - } - boolean hasNonNoOp = false; - for (ImMethod m : groupMethods) { - if (!isNoOpImplementation(m) && m.getImplementation() != null) { - hasNonNoOp = true; - break; - } - } - return hasNonNoOp && isNoOpImplementation(chosen); + + " candidates=[" + candidates + "]"; + WLogger.trace(line); } private String methodSortKey(ImMethod m) { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java index 6cf645999..1a9bc0d7d 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaTranslationTests.java @@ -141,10 +141,16 @@ private List subclassCreateClasses(String output, String baseName) { } private String compileLuaWithRunArgs(String testName, boolean withStdLib, String... lines) { + return compileLuaWithCUs(testName, withStdLib, Collections.emptyList(), lines); + } + + private String compileLuaWithCUs(String testName, boolean withStdLib, List extraCUs, String... lines) { RunArgs runArgs = new RunArgs().with("-lua", "-inline", "-localOptimizations", "-stacktraces"); WurstGui gui = new WurstGuiCliImpl(); WurstCompilerJassImpl compiler = new WurstCompilerJassImpl(null, gui, null, runArgs); - List inputs = Collections.singletonList(new CU(testName + ".wurst", String.join("\n", lines))); + List inputs = new ArrayList<>(); + inputs.addAll(extraCUs); + inputs.add(new CU(testName + ".wurst", String.join("\n", lines))); WurstModel model = parseFiles(Collections.emptyList(), inputs, withStdLib, compiler); assertNotNull("parse returned null model, errors = " + gui.getErrorList(), model); @@ -2316,22 +2322,26 @@ public void wurstInternalNativesDoNotGetNilSafetyWrapper() throws IOException { } @Test - public void isLuaMagicConstantIsTrueInLuaMode() throws IOException { - // isLua must be inlined to true in Lua mode by GlobalsInliner, - // so backend-specific code paths can be selected at compile time. - test().testLua(true).withStdLib().lines( + public void isLuaMagicConstantIsTrueInLuaMode() { + // isLua must be replaced with true by LuaNativeLowering so the optimizer can + // prune Jass-only branches at compile time. This test does NOT use withStdLib() + // to avoid dependence on the stdlib version pin in StdLib.java. + CU magicFunctions = new CU("MagicFunctions.wurst", + "package MagicFunctions\npublic constant isLua = false\n"); + String compiled = compileLuaWithCUs( + "LuaTranslationTests_isLuaMagicConstantIsTrueInLuaMode", + false, + Collections.singletonList(magicFunctions), "package Test", "import MagicFunctions", + "native print(string s)", "init", " if isLua", " print(\"lua-path\")", " else", " print(\"jass-path\")" ); - String compiled = Files.toString( - new File("test-output/lua/LuaTranslationTests_isLuaMagicConstantIsTrueInLuaMode.lua"), - Charsets.UTF_8); - // After inlining, the raw isLua variable must not appear in Lua output + // After LuaNativeLowering, all reads of MagicFunctions_isLua must be inlined to true assertFalse("MagicFunctions_isLua must be inlined away in Lua mode", compiled.contains("MagicFunctions_isLua")); // The lua-path branch must be preserved assertTrue("lua-path branch must be present", compiled.contains("lua-path")); From a9704d703f92c87580bbc0ffbb6fc09b8a06b879 Mon Sep 17 00:00:00 2001 From: Frotty Date: Wed, 8 Apr 2026 11:09:02 +0200 Subject: [PATCH 6/6] add jass->wurst access error --- .../generate-obj-mappings.ts | 446 ++++++++++++++++++ .../languageserver/ProjectConfigBuilder.java | 7 +- .../validation/WurstValidator.java | 20 + .../tests/CompilationUnitTests.java | 38 ++ 4 files changed, 509 insertions(+), 2 deletions(-) rename generate-obj-mappings.ts => HelperScripts/generate-obj-mappings.ts (54%) diff --git a/generate-obj-mappings.ts b/HelperScripts/generate-obj-mappings.ts similarity index 54% rename from generate-obj-mappings.ts rename to HelperScripts/generate-obj-mappings.ts index b754da22e..fb548a896 100644 --- a/generate-obj-mappings.ts +++ b/HelperScripts/generate-obj-mappings.ts @@ -18,6 +18,8 @@ const HELPER_ABILITY_FILE = "./HelperScripts/AbilityObjEditing.wurst"; const UNIT_BALANCE_SLK = "./HelperScripts/unitbalance.slk"; const OUT_FILE = "./de.peeeq.wurstscript/src/main/resources/stdlib-obj-mappings.json"; +const KB_OUT_FILE = "./HelperScripts/wc3-knowledge-base.json"; +const GAMEDATA_DIR = "./HelperScripts/gamedata"; // --------------------------------------------------------------------------- // Types @@ -390,6 +392,422 @@ function generateJson( return JSON.stringify(json, null, 2); } +// =========================================================================== +// KNOWLEDGE BASE GENERATION +// Reads all gamedata SLK/txt files and produces wc3-knowledge-base.json — +// a self-contained description of all WC3 object types, their field schemas +// (display names, types, categories) and all base-game object records. +// =========================================================================== + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tryRead(path: string): string | null { + try { return Deno.readTextFileSync(path); } catch (_) { return null; } +} + +function gdPath(file: string): string { + return `${GAMEDATA_DIR}/${file}`; +} + +// --------------------------------------------------------------------------- +// WorldEditStrings.txt → Map<"WESTRING_FOO", "Foo"> +// --------------------------------------------------------------------------- + +function parseWEStrings(content: string): Map { + const map = new Map(); + for (const rawLine of content.split("\n")) { + const line = rawLine.replace(/\r$/, "").trim(); + if (!line || line.startsWith("[") || line.startsWith(";") || line.startsWith("//")) continue; + const eq = line.indexOf("="); + if (eq < 1) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + map.set(key, val); + } + return map; +} + +// --------------------------------------------------------------------------- +// Generic SLK parser +// Parses the SYLK-subset format used by WC3 game data files. +// Returns a flat list of row-objects keyed by the column names from row Y=1. +// --------------------------------------------------------------------------- + +interface SLKRow extends Record {} + +function parseSLKRows(content: string): { idColName: string; rows: SLKRow[] } { + const grid: Record> = {}; + let curY: number | null = null; + + for (const rawLine of content.split("\n")) { + const line = rawLine.replace(/\r$/, ""); + if (!line.startsWith("C;")) continue; + + const parts = line.slice(2).split(";"); + let x: number | null = null; + let y: number | null = null; + let k: string | number | null = null; + + for (const p of parts) { + if (p.startsWith("X")) { + x = parseInt(p.slice(1)); + } else if (p.startsWith("Y")) { + y = parseInt(p.slice(1)); + } else if (p.startsWith("K")) { + const raw = p.slice(1); + if (!raw || raw === "-" || raw === "_" || raw === " - ") { + k = null; + } else if (raw.startsWith('"')) { + k = raw.endsWith('"') ? raw.slice(1, -1) : raw.slice(1); + } else { + const n = parseFloat(raw); + k = isNaN(n) ? raw : n; + } + } + } + + if (y !== null) curY = y; + if (curY === null || x === null) continue; + if (!grid[curY]) grid[curY] = {}; + grid[curY][x] = k; + } + + // Y=1 is the header row; X=1 is always the ID column. + const headerRow = grid[1] ?? {}; + const columns: Record = {}; + for (const [xStr, val] of Object.entries(headerRow)) { + if (typeof val === "string") columns[parseInt(xStr)] = val; + } + const idColName = columns[1] ?? "id"; + + const rows: SLKRow[] = []; + for (const y of Object.keys(grid).map(Number).sort((a, b) => a - b)) { + if (y === 1) continue; + const gridRow = grid[y]; + const idVal = gridRow[1]; + if (idVal === null || idVal === undefined) continue; + + const obj: SLKRow = {}; + for (const [xStr, val] of Object.entries(gridRow)) { + const colName = columns[parseInt(xStr)]; + if (colName && val !== null && val !== undefined) obj[colName] = val; + } + if (Object.keys(obj).length > 0) rows.push(obj); + } + + return { idColName, rows }; +} + +/** Index SLK rows by their ID column (X=1). */ +function slkToMap(content: string): Map { + const { idColName, rows } = parseSLKRows(content); + const map = new Map(); + for (const row of rows) { + const id = row[idColName]; + if (typeof id === "string" && id) map.set(id, row); + } + return map; +} + +// --------------------------------------------------------------------------- +// INI-style func.txt / skin.txt parser +// Sections like [UnitId] followed by Key=Value lines. +// --------------------------------------------------------------------------- + +function parseFuncTxt(content: string): Map> { + const result = new Map>(); + let current: Record | null = null; + + for (const rawLine of content.split("\n")) { + const line = rawLine.replace(/\r$/, ""); + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("//")) continue; + + if (trimmed.startsWith("[") && trimmed.includes("]")) { + const id = trimmed.slice(1, trimmed.indexOf("]")).trim(); + if (!result.has(id)) result.set(id, {}); + current = result.get(id)!; + } else if (current !== null) { + const eq = line.indexOf("="); + if (eq >= 1) { + const key = line.slice(0, eq).trim(); + const val = line.slice(eq + 1).trim(); + if (key) current[key] = val; + } + } + } + return result; +} + +/** Merge multiple func maps; first writer per key wins. */ +function mergeFuncMaps( + ...maps: Map>[] +): Map> { + const result = new Map>(); + for (const m of maps) { + for (const [id, fields] of m) { + if (!result.has(id)) result.set(id, {}); + const target = result.get(id)!; + for (const [k, v] of Object.entries(fields)) { + if (!(k in target)) target[k] = v; + } + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Field schema — derived from *metadata.slk files +// --------------------------------------------------------------------------- + +interface KBFieldSchema { + /** 4-char field ID used in war3map binary formats, e.g. "unam" */ + id: string; + /** Column name inside the source SLK, or key in Profile (func.txt) */ + field: string; + /** Source SLK tag, e.g. "unitData", "unitUI", "Profile", "ItemData" */ + slk: string; + /** Level index (-1 = not leveled; ≥ 0 = explicit level base) */ + index: number; + /** 1 = value repeats per level (ability leveled fields) */ + repeat: number; + /** Ability data slot pointer */ + data: number; + /** UI category key, e.g. "stats", "combat", "text" */ + category: string; + /** Human-readable display name (resolved from WorldEditStrings) */ + displayName: string; + /** Editor sort key */ + sort: string; + /** Data type: "string", "int", "real", "bool", "abilityList", etc. */ + type: string; + minVal: string | null; + maxVal: string | null; + useHero: boolean; + useUnit: boolean; + useBuilding: boolean; + useItem: boolean; + useCreep: boolean; + section: string | null; +} + +function buildSchemas( + metadataContent: string, + westrings: Map +): KBFieldSchema[] { + const { rows } = parseSLKRows(metadataContent); + const schemas: KBFieldSchema[] = []; + + function resolveStr(v: string | number | null | undefined): string { + if (v === null || v === undefined) return ""; + const s = String(v); + if (s.startsWith("WESTRING_")) return westrings.get(s) ?? s; + return s; + } + function toBool(v: string | number | null | undefined): boolean { + return v === 1 || v === "1"; + } + function toNum(v: string | number | null | undefined): number { + if (v === null || v === undefined) return 0; + const n = typeof v === "number" ? v : parseFloat(String(v)); + return isNaN(n) ? 0 : n; + } + function toNullStr(v: string | number | null | undefined): string | null { + return (v !== null && v !== undefined) ? String(v) : null; + } + + for (const row of rows) { + const id = row["ID"] as string; + if (!id) continue; + schemas.push({ + id, + field: resolveStr(row["field"]), + slk: resolveStr(row["slk"]), + index: toNum(row["index"]), + repeat: toNum(row["repeat"]), + data: toNum(row["data"]), + category: resolveStr(row["category"]), + displayName: resolveStr(row["displayName"]), + sort: resolveStr(row["sort"]), + type: resolveStr(row["type"]) || "string", + minVal: toNullStr(row["minVal"]), + maxVal: toNullStr(row["maxVal"]), + useHero: toBool(row["useHero"]), + useUnit: toBool(row["useUnit"]), + useBuilding: toBool(row["useBuilding"]), + useItem: toBool(row["useItem"]), + useCreep: toBool(row["useCreep"]), + section: toNullStr(row["section"]), + }); + } + return schemas; +} + +// --------------------------------------------------------------------------- +// Object records — merge SLK maps + func/skin map for each object type +// --------------------------------------------------------------------------- + +type ObjRecord = Record; + +function buildObjectMap( + slkMaps: Map[], + funcMap: Map> +): Record { + // Collect all IDs from every source + const allIds = new Set(); + for (const m of slkMaps) for (const id of m.keys()) allIds.add(id); + for (const id of funcMap.keys()) allIds.add(id); + + const result: Record = {}; + for (const id of allIds) { + const obj: ObjRecord = {}; + + // SLK sources first (order = priority) + for (const m of slkMaps) { + const row = m.get(id); + if (!row) continue; + for (const [k, v] of Object.entries(row)) { + if (!(k in obj) && v !== null && v !== undefined) { + obj[k] = v as string | number; + } + } + } + + // func.txt / skin.txt fields + const funcRow = funcMap.get(id); + if (funcRow) { + for (const [k, v] of Object.entries(funcRow)) { + if (!(k in obj) && v !== "") obj[k] = v; + } + } + + result[id] = obj; + } + return result; +} + +// --------------------------------------------------------------------------- +// Top-level knowledge base builder +// --------------------------------------------------------------------------- + +function generateKnowledgeBase(westrings: Map): object { + function loadSLK(file: string): Map { + const c = tryRead(gdPath(file)); + return c ? slkToMap(c) : new Map(); + } + function loadFunc(...files: string[]): Map> { + const maps: Map>[] = []; + for (const f of files) { + const c = tryRead(gdPath(f)); + if (c) maps.push(parseFuncTxt(c)); + } + return mergeFuncMaps(...maps); + } + function loadSchema(file: string): KBFieldSchema[] { + const c = tryRead(gdPath(file)); + return c ? buildSchemas(c, westrings) : []; + } + + // --- Field schemas from metadata files --- + const unitMeta = loadSchema("unitmetadata.slk"); + const abilityMeta = loadSchema("abilitymetadata.slk"); + const buffMeta = loadSchema("abilitybuffmetadata.slk"); + const destructableMeta = loadSchema("destructablemetadata.slk"); + const upgradeMeta = [ + ...loadSchema("upgrademetadata.slk"), + ...loadSchema("upgradeeffectmetadata.slk"), + ]; + + // --- Object data: merge SLK + func/skin sources per object type --- + + // Units — five SLK files, all keyed by the same 4-char unit ID + const unitFunc = loadFunc( + "humanunitfunc.txt", "orcunitfunc.txt", "nightelfunitfunc.txt", + "undeadunitfunc.txt", "neutralunitfunc.txt", "campaignunitfunc.txt", + "unitskin.txt", "unitweaponsskin.txt", "unitaddons.txt", + ); + const unitObjects = buildObjectMap( + [ + loadSLK("unitdata.slk"), + loadSLK("unitui.slk"), + loadSLK("unitbalance.slk"), + loadSLK("unitweapons.slk"), + loadSLK("unitabilities.slk"), + ], + unitFunc, + ); + + // Abilities + const abilityFunc = loadFunc( + "humanabilityfunc.txt", "orcabilityfunc.txt", "nightelfabilityfunc.txt", + "undeadabilityfunc.txt", "neutralabilityfunc.txt", "campaignabilityfunc.txt", + "commonabilityfunc.txt", "itemabilityfunc.txt", "commandfunc.txt", + "abilityskin.txt", + ); + const abilityObjects = buildObjectMap([loadSLK("abilitydata.slk")], abilityFunc); + + // Buffs/effects — abilitybuffdata.slk; buff IDs may also appear in ability func files + const buffObjects = buildObjectMap([loadSLK("abilitybuffdata.slk")], abilityFunc); + + // Items + const itemFunc = loadFunc( + "itemfunc.txt", "itemskin.txt", "itemaddons.txt", + ); + const itemObjects = buildObjectMap([loadSLK("itemdata.slk")], itemFunc); + + // Destructables + const destructableFunc = loadFunc( + "destructableskin.txt", "destructableaddons.txt", + ); + const destructableObjects = buildObjectMap( + [loadSLK("destructabledata.slk")], destructableFunc, + ); + + // Upgrades + const upgradeFunc = loadFunc( + "humanupgradefunc.txt", "orcupgradefunc.txt", "nightelfupgradefunc.txt", + "undeadupgradefunc.txt", "neutralupgradefunc.txt", "campaignupgradefunc.txt", + "upgradeskin.txt", + ); + const upgradeObjects = buildObjectMap([loadSLK("upgradedata.slk")], upgradeFunc); + + // unitmetadata.slk covers units, heroes, buildings AND items; split by use-flags. + return { + /** + * Field schemas per object type. + * Each entry describes one editable field: its 4-char ID, human-readable + * display name (resolved from WorldEditStrings), data type, UI category, + * valid range, and which object sub-types use it. + */ + fieldSchemas: { + unit: unitMeta.filter((s) => s.useUnit), + hero: unitMeta.filter((s) => s.useHero), + building: unitMeta.filter((s) => s.useBuilding), + item: unitMeta.filter((s) => s.useItem), + ability: abilityMeta, + buff: buffMeta, + destructable: destructableMeta, + upgrade: upgradeMeta, + }, + /** + * All base-game objects keyed by their 4-char ID. + * Each record is a flat map of field-name → value, merging every SLK + * and Profile (func.txt/skin.txt) source for that object type. + */ + objects: { + unit: unitObjects, + ability: abilityObjects, + buff: buffObjects, + item: itemObjects, + destructable: destructableObjects, + upgrade: upgradeObjects, + }, + }; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -490,3 +908,31 @@ const mappedCount = Object.keys(parsed.abilityClassByBaseId).length; const totalClasses = Object.keys(parsed.classOwnFields).length; console.log(`\nAbility base IDs mapped: ${mappedCount}`); console.log(`Total classes with own fields: ${totalClasses}`); + +// --------------------------------------------------------------------------- +// Knowledge base generation (reads from HelperScripts/gamedata/) +// --------------------------------------------------------------------------- + +const westringsContent = tryRead(gdPath("WorldEditStrings.txt")); +if (!westringsContent) { + console.log("\nHelperScripts/gamedata/ not found — skipping knowledge base generation."); + console.log("Place WC3 game data files in HelperScripts/gamedata/ to enable this output."); +} else { + console.log("\n--- Generating wc3-knowledge-base.json ---"); + const westrings = parseWEStrings(westringsContent); + console.log(`Parsed ${westrings.size} WorldEditStrings entries`); + + const kb = generateKnowledgeBase(westrings); + const kbJson = JSON.stringify(kb, null, 2); + Deno.writeTextFileSync(KB_OUT_FILE, kbJson); + console.log(`Generated: ${KB_OUT_FILE}`); + + // Summary + const kbParsed = kb as { fieldSchemas: Record; objects: Record> }; + for (const [type, schemas] of Object.entries(kbParsed.fieldSchemas)) { + const count = (schemas as unknown[]).length; + const objCount = Object.keys(kbParsed.objects[type] ?? {}).length; + if (count > 0 || objCount > 0) + console.log(` ${type}: ${count} field schemas, ${objCount} base objects`); + } +} 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 6c9f9162c..a59ac87a6 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 @@ -85,11 +85,14 @@ public static MapRequest.CompilationResult apply(WurstProjectConfigData projectC // Prefer the previously-injected script (with correct config() body) over the // raw map script. If it doesn't exist yet (e.g. first Lua build after a JASS-only // cache), fall through to re-inject so the config() body is never stale. + // Also re-inject if war3map.j was modified after the cached script was written. File cachedInjectedScript = new File(buildDir, outputScriptName); - if (cachedInjectedScript.exists()) { + boolean cachedScriptStale = !cachedInjectedScript.exists() + || mapScript.lastModified() > cachedInjectedScript.lastModified(); + if (!cachedScriptStale) { result.script = cachedInjectedScript; } else if (StringUtils.isNotBlank(buildMapData.getName())) { - WLogger.info("Cached injected script missing, re-injecting config"); + WLogger.info("war3map.j changed or cached script missing, re-injecting config"); applyBuildMapData(projectConfig, mapScript, buildDir, w3data, w3I, result, configHash, outputScriptName); } // else result.script stays as mapScript (no wurst.build name configured) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/WurstValidator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/WurstValidator.java index 1b21a10d8..7bb45a4f1 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/WurstValidator.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/WurstValidator.java @@ -2208,6 +2208,7 @@ private void checkVarRef(NameRef e, boolean dynamicContext) { return; } NameDef def = link.getDef(); + checkJassAccessingWurstSymbol(e, def); if (def instanceof GlobalVarDef) { GlobalVarDef g = (GlobalVarDef) def; if (g.attrIsDynamicClassMember() && !dynamicContext) { @@ -2389,6 +2390,7 @@ private void checkFuncRef(FuncRef ref) { if (called == null) { return; } + checkJassAccessingWurstSymbol(ref, called.getDef()); WScope scope = ref.attrNearestFuncDef(); if (scope == null) { scope = ref.attrNearestScope(); @@ -2398,6 +2400,24 @@ private void checkFuncRef(FuncRef ref) { } } + /** + * Warn when Jass code (war3map.j or any .j file) references a symbol defined in a Wurst (.wurst/.jurst) file. + * The Wurst→Jass relationship is one-way: Wurst compiles to Jass, so Wurst can call Jass natives, + * but Jass cannot call Wurst-generated functions (their names get mangled by the Wurst compiler). + */ + private void checkJassAccessingWurstSymbol(Element ref, NameDef def) { + String callerFile = ref.attrSource().getFile(); + if (!callerFile.endsWith(".j")) { + return; + } + String defFile = def.attrSource().getFile(); + if (defFile.endsWith(".wurst") || defFile.endsWith(".jurst")) { + ref.addError("Jass code cannot access Wurst symbol '" + def.getName() + "' defined in " + defFile + ".\n" + + "The Wurst\u2192Jass relationship is one-way: Wurst can call Jass, but Jass cannot call Wurst-defined symbols " + + "(their names are mangled by the Wurst compiler)."); + } + } + private void checkNameRefDeprecated(Element trace, NameLink link) { if (link != null) { checkNameRefDeprecated(trace, link.getDef()); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompilationUnitTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompilationUnitTests.java index 8792bfcb0..d75d0b17c 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompilationUnitTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompilationUnitTests.java @@ -100,4 +100,42 @@ public void jassLocalHandleReadBeforeLaterWriteStillWarns() { ); } + /** Jass code calling a Wurst-defined top-level function must produce a validator error. */ + @Test + public void jassCallingWurstFunctionIsError() { + test() + .setStopOnFirstError(false) + .executeProg(false) + .expectError("Jass code cannot access Wurst symbol") + .compilationUnits( + compilationUnit("mylib.wurst", + "// Jass-compat function defined in a .wurst file", + "function wurstHelper takes nothing returns nothing", + "endfunction" + ), + compilationUnit("war3map.j", + "function jassFunc takes nothing returns nothing", + " call wurstHelper()", + "endfunction" + ) + ); + } + + /** Wurst code calling a plain Jass function is valid (one-way relationship). */ + @Test + public void wurstCallingJassFunctionIsOk() { + testAssertOk(false, false, + compilationUnit("mymap.j", + "function jassHelper takes nothing returns nothing", + "endfunction" + ), + compilationUnit("mylib.wurst", + "package mylib", + "init", + " jassHelper()", + "endpackage" + ) + ); + } + }