From 37a46b33cf5961014cab27b157c0ed5c7daa719e Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 10 Sep 2026 16:34:59 +0200 Subject: [PATCH 1/2] Accept a new-generic type parameter as a keyed-table key. --- .../imtranslation/LuaKeyedTable.java | 18 ++++++++- .../tests/LuaTranslationTests.java | 40 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaKeyedTable.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaKeyedTable.java index faa65f1a7..2bd9aab17 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaKeyedTable.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaKeyedTable.java @@ -2,7 +2,10 @@ import de.peeeq.wurstscript.CompilerIntrinsics; import de.peeeq.wurstscript.ast.FuncDef; +import de.peeeq.wurstscript.jassIm.ImAnyType; import de.peeeq.wurstscript.jassIm.ImFunction; +import de.peeeq.wurstscript.jassIm.ImType; +import de.peeeq.wurstscript.jassIm.ImTypeVarRef; import de.peeeq.wurstscript.jassIm.ImVoid; import de.peeeq.wurstscript.types.TypesHelper; @@ -74,6 +77,19 @@ public static String nativeStubFor(ImFunction f) { private static boolean isKeyedPair(ImFunction f) { return f.getParameters().size() == 2 && TypesHelper.isIntType(f.getParameters().get(0).getType()) - && TypesHelper.isIntType(f.getParameters().get(1).getType()); + && isKeyType(f.getParameters().get(1).getType()); + } + + /** + * Types a key may have. + * + *

A {@code T:} type parameter is the point of this: new generics are erased on Lua rather + * than squeezed through {@code castTo int} the way the old {@code } containers are, so the + * value arriving here is the element itself and becomes the table key directly - which is what + * makes native Lua hashing possible at all. {@code int} stays accepted for keys that are + * already integers, and an erased type parameter can also present as ImAnyType by this point. + */ + private static boolean isKeyType(ImType t) { + return TypesHelper.isIntType(t) || t instanceof ImTypeVarRef || t instanceof ImAnyType; } } 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 1c596b124..b1b4a27ef 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 @@ -1868,6 +1868,46 @@ public void genericOverrideChainBindsGlobalStateSlotToMostSpecificImplInLua() th assertDoesNotContainRegex(compiled, "GlobalCheckState\\." + dispatchedSlot + "\\s*=\\s*NoOpState_NoOpState_update"); } + /** + * A generic key is the whole point: new generics are erased on Lua rather than cast to int + * like the old containers, so the element itself becomes the table key and Lua hashes it + * natively. Bodies are trivial because these are Lua-only primitives - callers guard on isLua. + */ + @Test + public void keyedTableGenericKeyReachesLuaUncast() throws IOException { + test().testLua(true).withStdLib().lines( + "package KeyedTable", + "@compilerintrinsic public function keyedTableCreate() returns int", + " return 0", + "@compilerintrinsic public function keyedTableAdd(int tbl, T key)", + " skip", + "@compilerintrinsic public function keyedTableContains(int tbl, T key) returns boolean", + " return false", + "endpackage", + "package Test", + "import KeyedTable", + "init", + " let t = keyedTableCreate()", + " let u = CreateUnit(Player(0), 'hfoo', 0., 0., 0.)", + " keyedTableAdd(t, u)", + " if keyedTableContains(t, u)", + " print(\"present\")", + "endpackage"); + + String compiled = Files.toString( + new File("test-output/lua/LuaTranslationTests_keyedTableGenericKeyReachesLuaUncast.lua"), + Charsets.UTF_8); + + assertTrue("a generic key must still lower to the keyed-table stubs", + compiled.contains("__wurst_keyedTableAdd")); + assertTrue("add is a single store", getFunctionBody(compiled, "__wurst_keyedTableAdd").contains("] = true")); + + // The unit must be handed over as itself. An index round-trip would show up here. + String init = getFunctionBody(compiled, "init_Test"); + assertFalse("the element must not be converted to a class index: " + init, + init.contains("__wurst_classFromIndex")); + } + /** * KeyedTable source shared by the tests below: the Jass path built on the library's hashtable * wrapper, which the Lua backend replaces with a table keyed directly by the element. From 1023b615008aa646ec04587ff19b72a29dba6874 Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 10 Sep 2026 17:01:23 +0200 Subject: [PATCH 2/2] Erase type variables from native stub signatures so no stub carries a free one. --- .../imtranslation/LuaNativeLowering.java | 17 ++++- .../tests/LuaTranslationTests.java | 62 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 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 51bfb04ef..6d278d64b 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 @@ -532,16 +532,29 @@ private static ImFunctionCall call(ImFunction f, ImExpr... args) { 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)); + params.add(JassIm.ImVar(p.attrTrace(), erasedForStub(p.getType()), p.getName(), false)); } return JassIm.ImFunction( original.attrTrace(), name, JassIm.ImTypeVars(), params, - original.getReturnType().copy(), + erasedForStub(original.getReturnType()), JassIm.ImVars(), JassIm.ImStmts(), Collections.singletonList(FunctionFlagEnum.IS_NATIVE)); } + /** + * A type safe to put in a stub's signature. + * + *

Stubs are built with no type variables of their own, so copying an ImTypeVarRef would + * leave the stub referring to a variable owned by the function it replaced - a free variable, + * and malformed IM for every pass that walks types afterwards. A native stub is never generic: + * its body is hand-written Lua that does not consult the type, so erasing is the whole fix + * rather than rebinding a variable nothing will read. + */ + private static ImType erasedForStub(ImType t) { + return t instanceof ImTypeVarRef ? JassIm.ImAnyType() : t.copy(); + } + /** * Creates a nil-safety wrapper for {@code bjNative}. * 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 b1b4a27ef..ddab3f693 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 @@ -8,6 +8,10 @@ import de.peeeq.wurstscript.ast.WurstModel; import de.peeeq.wurstscript.gui.WurstGui; import de.peeeq.wurstscript.gui.WurstGuiCliImpl; +import de.peeeq.wurstscript.jassIm.ImFunction; +import de.peeeq.wurstscript.jassIm.ImType; +import de.peeeq.wurstscript.jassIm.ImTypeVarRef; +import de.peeeq.wurstscript.jassIm.ImVar; import de.peeeq.wurstscript.luaAst.LuaAst; import de.peeeq.wurstscript.luaAst.LuaCompilationUnit; import de.peeeq.wurstscript.luaAst.LuaExpr; @@ -1868,6 +1872,62 @@ public void genericOverrideChainBindsGlobalStateSlotToMostSpecificImplInLua() th assertDoesNotContainRegex(compiled, "GlobalCheckState\\." + dispatchedSlot + "\\s*=\\s*NoOpState_NoOpState_update"); } + /** + * Native stubs carry no type variables of their own, so a stub signature must never refer to + * one. Copying a T: parameter type verbatim leaves the stub pointing at a variable owned by + * the function it replaced - malformed IM for every later pass that walks types. + * + *

Asserted as a rule rather than through a symptom: the free reference does not break + * emission today, so a behavioural test would pass with or without the fix. + */ + @Test + public void nativeStubsCarryNoFreeTypeVariables() { + WurstGui gui = new WurstGuiCliImpl(); + WurstCompilerJassImpl compiler = new WurstCompilerJassImpl(null, gui, null, + new RunArgs().with("-lua")); + List inputs = new ArrayList<>(); + inputs.add(new CU("nativeStubsCarryNoFreeTypeVariables.wurst", String.join(System.lineSeparator(), + "package KeyedTable", + "@annotation public function compilerintrinsic()", + "@compilerintrinsic public function keyedTableAdd(int tbl, T key)", + " skip", + "@compilerintrinsic public function keyedTableContains(int tbl, T key) returns boolean", + " return false", + "endpackage", + "package Test", + "import KeyedTable", + "init", + " keyedTableAdd(1, 7)", + " let hit = keyedTableContains(1, 7)", + "endpackage"))); + + WurstModel model = parseFiles(Collections.emptyList(), inputs, false, compiler); + assertNotNull("parse returned null model, errors = " + gui.getErrorList(), model); + compiler.checkProg(model); + assertTrue("unexpected compile errors: " + gui.getErrorList(), gui.getErrorList().isEmpty()); + compiler.translateProgToIm(model); + compiler.runCompiletime(WurstProjectConfigData.empty(), false, false); + compiler.transformProgToLua(); + + for (ImFunction f : compiler.getImProg().getFunctions()) { + if (!f.isNative()) { + continue; + } + for (ImVar p : f.getParameters()) { + assertFalse(f.getName() + " parameter " + p.getName() + + " refers to a type variable the stub does not declare", + isFreeTypeVar(p.getType(), f)); + } + assertFalse(f.getName() + " return type refers to a type variable the stub does not declare", + isFreeTypeVar(f.getReturnType(), f)); + } + } + + private static boolean isFreeTypeVar(ImType t, ImFunction owner) { + return t instanceof ImTypeVarRef ref + && !owner.getTypeVariables().contains(ref.getTypeVariable()); + } + /** * A generic key is the whole point: new generics are erased on Lua rather than cast to int * like the old containers, so the element itself becomes the table key and Lua hashes it @@ -1875,7 +1935,7 @@ public void genericOverrideChainBindsGlobalStateSlotToMostSpecificImplInLua() th */ @Test public void keyedTableGenericKeyReachesLuaUncast() throws IOException { - test().testLua(true).withStdLib().lines( + test().testLua(true).inline().withStdLib().lines( "package KeyedTable", "@compilerintrinsic public function keyedTableCreate() returns int", " return 0",