From fa0eba3ea9ed024fa9f5a9ebc574a3f075e9fe90 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 16:00:38 +0200 Subject: [PATCH 01/11] Allow nested generic specialization keys --- .../imtranslation/GenericTypes.java | 4 - .../tests/LuaBackendAuditTests.java | 216 ++++++++++++++++++ 2 files changed, 216 insertions(+), 4 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypes.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypes.java index 5c54dafb4..fd7c3f586 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypes.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypes.java @@ -1,6 +1,5 @@ package de.peeeq.wurstscript.translation.imtranslation; -import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import de.peeeq.wurstscript.jassIm.*; @@ -16,9 +15,6 @@ class GenericTypes { public GenericTypes(List typeArguments) { - for (ImTypeArgument ta : typeArguments) { - Preconditions.checkArgument(!EliminateGenerics.isGenericType(ta.getType()), "Type arguments must not be generic: " + typeArguments); - } this.typeArguments = ImmutableList.copyOf(typeArguments); } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 7f5d1fef1..3cfe96658 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -934,6 +934,222 @@ public void tupleSpecializedInterfaceDispatchDoesNotRootErasedStaticInitializer( ); } + @Test + public void nestedConcreteGenericStaticStorageCompilesInLua() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "class List", + " static T array store", + " int size", + " construct()", + " function add(T value)", + " store[size] = value", + " size++", + " function get(int index) returns T", + " return store[index]", + "class Item", + " int value", + " construct(int value)", + " this.value = value", + "init", + " let nested = new List>()", + " nested.add(new List())", + " nested.get(0).add(new Item(7))", + " let pairs = new List()", + " pairs.add(pair(2, 3))", + " if nested.get(0).get(0).value == 7 and pairs.get(0).x == 2", + " testSuccess()" + ); + } + + @Test + public void randomizedNestedGenericTupleClassShapesMatchAllBackends() { + record Shape(String type, String value, int constructions) {} + + Random random = new Random(0x6E3571A9L); + List declarations = new ArrayList<>(); + List shapes = new ArrayList<>(); + for (int i = 0; i < 18; i++) { + int first = random.nextInt(17) + 1; + int second = random.nextInt(17) + 1; + Shape shape = i % 2 == 0 + ? new Shape("int", Integer.toString(first), 0) + : new Shape("pair", "pair(" + first + ", " + second + ")", + 0); + int depth = 2 + random.nextInt(3); + for (int d = 0; d < depth; d++) { + switch ((i + d + random.nextInt(3)) % 3) { + case 0 -> shape = new Shape("Box<" + shape.type() + ">", + "new Box<" + shape.type() + ">(" + shape.value() + ")", + shape.constructions() + 1); + case 1 -> shape = new Shape("Child<" + shape.type() + ">", + "new Child<" + shape.type() + ">(" + shape.value() + ")", + shape.constructions() + 1); + case 2 -> { + String tupleName = "Wrapped" + i + "_" + d; + int tag = random.nextInt(11) + 1; + declarations.add("tuple " + tupleName + "(" + shape.type() + + " value, int tag)"); + shape = new Shape(tupleName, + tupleName + "(" + shape.value() + ", " + tag + ")", + shape.constructions()); + } + } + } + shapes.add(shape); + } + + List source = new ArrayList<>(); + source.add("package Test"); + source.add("native testSuccess()"); + source.add("tuple pair(int x, int y)"); + source.add("int constructions"); + source.add("int writes"); + source.add("interface Marker"); + source.add("class Box implements Marker"); + source.add(" T value"); + source.add(" construct(T value)"); + source.add(" this.value = value"); + source.add(" constructions++"); + source.add("class Child extends Box"); + source.add(" construct(T value)"); + source.add(" super(value)"); + source.add("class Vault"); + source.add(" static T value"); + source.add(" static function set(T newValue)"); + source.add(" value = newValue"); + source.add(" writes++"); + source.addAll(declarations); + source.add("init"); + int expectedConstructions = 0; + for (Shape shape : shapes) { + source.add(" Vault<" + shape.type() + ">.set(" + shape.value() + ")"); + expectedConstructions += shape.constructions(); + } + source.add(" if writes == " + shapes.size() + + " and constructions == " + expectedConstructions); + source.add(" testSuccess()"); + + test().testLua(true).executeProg().lines(source.toArray(new String[0])); + } + + @Test + public void randomizedClassInterfaceModuleDispatchMatchesAllBackends() { + Random random = new Random(0xD15A7C4L); + List source = new ArrayList<>(); + Collections.addAll(source, + "package Test", + "native testSuccess()", + "int destroyed", + "interface Primary", + " function score() returns int", + "interface Secondary", + " function bonus() returns int", + "module Payload", + " int moduleValue", + " function payload() returns int", + " return moduleValue * 3", + " ondestroy", + " destroyed++", + "class Root implements Primary", + " use Payload", + " int base", + " construct(int base)", + " this.base = base", + " moduleValue = base + 1", + " override function score() returns int", + " return base + payload()", + "class Alpha extends Root implements Secondary", + " construct(int base)", + " super(base)", + " override function score() returns int", + " return super.score() + 11", + " override function bonus() returns int", + " return base * 5 + 1", + "class AlphaLeaf extends Alpha", + " construct(int base)", + " super(base)", + " override function score() returns int", + " return super.score() * 2", + " override function bonus() returns int", + " return super.bonus() + 5", + "class Beta extends Root implements Secondary", + " construct(int base)", + " super(base)", + " override function score() returns int", + " return super.score() - 7", + " override function bonus() returns int", + " return base * 7 + 2", + "module ScoreContract", + " abstract function score() returns int", + "module StandaloneScore", + " use ScoreContract", + " use Payload", + " override function score() returns int", + " return moduleValue * 9 + 4", + "class ModuleOnly implements Primary", + " use StandaloneScore", + " construct(int base)", + " moduleValue = base", + "function viaPrimary(Primary value) returns int", + " return value.score()", + "function viaSecondary(Secondary value) returns int", + " return value.bonus()", + "init", + " int checksum = 0"); + + int expected = 0; + int objectCount = 40; + for (int i = 0; i < objectCount; i++) { + int value = random.nextInt(30) + 1; + int kind = random.nextInt(5); + String className; + int score; + Integer bonus = null; + switch (kind) { + case 0 -> { + className = "Root"; + score = 4 * value + 3; + } + case 1 -> { + className = "Alpha"; + score = 4 * value + 14; + bonus = value * 5 + 1; + } + case 2 -> { + className = "AlphaLeaf"; + score = (4 * value + 14) * 2; + bonus = value * 5 + 6; + } + case 3 -> { + className = "Beta"; + score = 4 * value - 4; + bonus = value * 7 + 2; + } + default -> { + className = "ModuleOnly"; + score = value * 9 + 4; + } + } + source.add(" let object" + i + " = new " + className + "(" + value + ")"); + source.add(" Primary primary" + i + " = object" + i); + source.add(" checksum += viaPrimary(primary" + i + ")"); + expected += score; + if (bonus != null) { + source.add(" Secondary secondary" + i + " = object" + i); + source.add(" checksum += viaSecondary(secondary" + i + ")"); + expected += bonus; + } + source.add(" destroy object" + i); + } + source.add(" if checksum == " + expected + " and destroyed == " + objectCount); + source.add(" testSuccess()"); + + test().testLua(true).executeProg().lines(source.toArray(new String[0])); + } + @Test public void tupleSpecializedStaticInitializerCycleDoesNotRootErasedCopy() throws IOException { test().testLua(true).executeProg().lines( From 15c16ef6d6edc70d32f5f76f11387c6e131ffa10 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 16:13:18 +0200 Subject: [PATCH 02/11] Normalize nested specialization bindings --- .../imtranslation/GenericTypes.java | 56 +++++++++++++++- .../imtranslation/GenericTypesTests.java | 64 +++++++++++++++++++ .../tests/LuaBackendAuditTests.java | 15 ++++- 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 de.peeeq.wurstscript/src/test/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypesTests.java diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypes.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypes.java index fd7c3f586..53ea41c67 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypes.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypes.java @@ -32,7 +32,7 @@ public boolean equals(Object o) { for (int i = 0; i < typeArguments.size(); i++) { ImTypeArgument t1 = typeArguments.get(i); ImTypeArgument t2 = ot.typeArguments.get(i); - if (!t1.getType().equalsType(t2.getType())) { + if (!equalTypeIgnoringBindings(t1.getType(), t2.getType())) { return false; } // Deliberately not comparing the type class binding. It is only a fast path for @@ -46,6 +46,60 @@ public boolean equals(Object o) { return false; } + /** + * Type-class bindings are dispatch metadata, not part of a specialization's structural type. + * Unlike the general IM type equality operation, this comparison therefore ignores bindings + * on every nested class-type argument, not just on the arguments wrapped by this key. + */ + private static boolean equalTypeIgnoringBindings(ImType left, ImType right) { + if (left instanceof ImArrayType) { + return right instanceof ImArrayType + && equalTypeIgnoringBindings(((ImArrayType) left).getEntryType(), + ((ImArrayType) right).getEntryType()); + } + if (left instanceof ImArrayTypeMulti) { + return right instanceof ImArrayTypeMulti + && equalTypeIgnoringBindings(((ImArrayTypeMulti) left).getEntryType(), + ((ImArrayTypeMulti) right).getEntryType()); + } + if (left instanceof ImTupleType) { + if (!(right instanceof ImTupleType)) { + return false; + } + ImTupleType leftTuple = (ImTupleType) left; + ImTupleType rightTuple = (ImTupleType) right; + if (leftTuple.getTypes().size() != rightTuple.getTypes().size()) { + return false; + } + for (int i = 0; i < leftTuple.getTypes().size(); i++) { + if (!equalTypeIgnoringBindings(leftTuple.getTypes().get(i), + rightTuple.getTypes().get(i))) { + return false; + } + } + return true; + } + if (left instanceof ImClassType) { + if (!(right instanceof ImClassType)) { + return false; + } + ImClassType leftClass = (ImClassType) left; + ImClassType rightClass = (ImClassType) right; + if (leftClass.getClassDef() != rightClass.getClassDef() + || leftClass.getTypeArguments().size() != rightClass.getTypeArguments().size()) { + return false; + } + for (int i = 0; i < leftClass.getTypeArguments().size(); i++) { + if (!equalTypeIgnoringBindings(leftClass.getTypeArguments().get(i).getType(), + rightClass.getTypeArguments().get(i).getType())) { + return false; + } + } + return true; + } + return left.equalsType(right); + } + @Override public int hashCode() { int res = 7; diff --git a/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypesTests.java b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypesTests.java new file mode 100644 index 000000000..71a8eca59 --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstscript/translation/imtranslation/GenericTypesTests.java @@ -0,0 +1,64 @@ +package de.peeeq.wurstscript.translation.imtranslation; + +import de.peeeq.wurstscript.ast.Ast; +import de.peeeq.wurstscript.jassIm.ImClass; +import de.peeeq.wurstscript.jassIm.ImClassType; +import de.peeeq.wurstscript.jassIm.ImFunction; +import de.peeeq.wurstscript.jassIm.ImMethod; +import de.peeeq.wurstscript.jassIm.ImSimpleType; +import de.peeeq.wurstscript.jassIm.ImTypeArgument; +import de.peeeq.wurstscript.jassIm.ImTypeClassFunc; +import de.peeeq.wurstscript.jassIm.JassIm; +import io.vavr.control.Either; +import org.testng.annotations.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.testng.Assert.assertEquals; + +public class GenericTypesTests { + + @Test + public void nestedTypeClassBindingsDoNotSplitSpecializationKeys() { + ImClass box = genericClass("Box"); + ImClass list = genericClass("List"); + ImSimpleType integer = JassIm.ImSimpleType("integer"); + ImTypeClassFunc requirement = JassIm.ImTypeClassFunc(Ast.NoExpr(), "toIndex", + JassIm.ImTypeVars(), JassIm.ImVars(), integer); + ImFunction instance = JassIm.ImFunction(Ast.NoExpr(), "intToIndex", JassIm.ImTypeVars(), + JassIm.ImVars(), integer, JassIm.ImVars(), JassIm.ImStmts(), List.of()); + + Map> binding = new LinkedHashMap<>(); + binding.put(requirement, Either.right(instance)); + ImClassType unboundBox = JassIm.ImClassType(box, + JassIm.ImTypeArguments(argument(integer, Collections.emptyMap()))); + ImClassType boundBox = JassIm.ImClassType(box, + JassIm.ImTypeArguments(argument(integer, binding))); + + GenericTypes unbound = key(list, unboundBox); + GenericTypes bound = key(list, boundBox); + + assertEquals(bound, unbound, + "type-class dispatch metadata must not change a structural specialization key"); + assertEquals(bound.hashCode(), unbound.hashCode()); + } + + private static GenericTypes key(ImClass list, ImClassType nestedType) { + ImClassType listType = JassIm.ImClassType(list, + JassIm.ImTypeArguments(argument(nestedType, Collections.emptyMap()))); + return new GenericTypes(List.of(argument(listType, Collections.emptyMap()))); + } + + private static ImTypeArgument argument(de.peeeq.wurstscript.jassIm.ImType type, + Map> binding) { + return JassIm.ImTypeArgument(type, binding); + } + + private static ImClass genericClass(String name) { + return JassIm.ImClass(Ast.NoExpr(), name, JassIm.ImTypeVars(JassIm.ImTypeVar("T")), + JassIm.ImVars(), JassIm.ImMethods(), JassIm.ImFunctions(), List.of()); + } +} diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 3cfe96658..97902de72 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -935,7 +935,7 @@ public void tupleSpecializedInterfaceDispatchDoesNotRootErasedStaticInitializer( } @Test - public void nestedConcreteGenericStaticStorageCompilesInLua() { + public void nestedConcreteGenericStaticStorageCompilesInLua() throws IOException { test().testLua(true).executeProg().lines( "package Test", "native testSuccess()", @@ -962,6 +962,19 @@ public void nestedConcreteGenericStaticStorageCompilesInLua() { " if nested.get(0).get(0).value == 7 and pairs.get(0).x == 2", " testSuccess()" ); + + String compiled = compiledLua("nestedConcreteGenericStaticStorageCompilesInLua"); + java.util.regex.Matcher storageDeclarations = java.util.regex.Pattern + .compile("(?m)^(List_store\\S*) = nil$") + .matcher(compiled); + List storageNames = new ArrayList<>(); + while (storageDeclarations.find()) { + storageNames.add(storageDeclarations.group(1)); + } + assertEquals("one erased class-like and one tuple-specialized storage slot are expected", + 2, storageNames.size()); + assertEquals("each structural List specialization must emit one storage slot", + 2L, storageNames.stream().distinct().count()); } @Test From 214566c8d3072e318294c1996dff6fe205a1a95d Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 16:27:39 +0200 Subject: [PATCH 03/11] Isolate Lua generic static storage --- .../peeeq/wurstio/WurstCompilerJassImpl.java | 7 +-- .../imtranslation/EliminateGenerics.java | 48 +++++++++++++++-- .../tests/LuaBackendAuditTests.java | 51 +++++++++++++++---- 3 files changed, 89 insertions(+), 17 deletions(-) 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 5043a07f4..105d4520f 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 @@ -875,10 +875,11 @@ public LuaCompilationUnit transformProgToLua() { ImAttrType.setWurstClassType(null); int stage; boolean specializeTupleValueTypes = containsTupleTypeArgument(); - if (containsGenericNewCall() || containsTypeClassDispatch() || specializeTupleValueTypes) { + EliminateGenerics luaGenerics = new EliminateGenerics(getImTranslator(), getImProg()); + if (containsGenericNewCall() || containsTypeClassDispatch() || specializeTupleValueTypes + || luaGenerics.hasGenericStatics()) { beginPhase(2, "Specialize generics for Lua-only concrete operations"); - new EliminateGenerics(getImTranslator(), getImProg()) - .transformGenericNewOnly(specializeTupleValueTypes); + luaGenerics.transformGenericNewOnly(specializeTupleValueTypes); // Remove phantom erased initialization before optimization can preserve only its side // effect. A specialized static owns its copied initializer unless the erased static is live. RemoveGarbage.removePhantomGenericStaticInitializers(getImProg(), getImTranslator()); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index 89563be53..821bbb0a2 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -127,10 +127,11 @@ public void transformGenericNewOnly() { public void transformGenericNewOnly(boolean specializeTupleValueTypes) { genericNewOnly = true; this.specializeTupleValueTypes = specializeTupleValueTypes; - if (specializeTupleValueTypes) { + identifyGenericGlobals(); + if (specializeTupleValueTypes || !globalToClass.isEmpty()) { addMemberTypeArguments(); - identifyGenericGlobals(); } + indexGenericGlobalUses(); collectUnspecializedGenericClassMethods(); // Specialising a constructor makes its result type concrete, which is what lets a method // call on that result resolve. Repeat until a pass finds nothing new; collection is @@ -151,6 +152,11 @@ public void transformGenericNewOnly(boolean specializeTupleValueTypes) { settleRemainingDispatches(); } + public boolean hasGenericStatics() { + identifyGenericGlobals(); + return !globalToClass.isEmpty(); + } + /** * Moves a specialisation's methods to the class its objects are actually allocated from. *

@@ -770,6 +776,9 @@ private boolean functionNeedsSpecialization(ImFunction function, Set */ private boolean functionNeedsSpecialization(ImFunction function, Set visitedFunctions, Set visitedMethods) { + if (needsGlobalSpecialization(function)) { + return true; + } if (!visitedFunctions.add(function)) { return false; } @@ -988,6 +997,16 @@ private boolean needsGlobalSpecialization(ImFunction f) { return o != null && !o.isEmpty(); } + private boolean classOwnsGenericGlobals(ImClass clazz) { + ImClass canonical = translator.canonical(clazz); + for (ImClass owner : globalToClass.values()) { + if (translator.canonical(owner) == canonical) { + return true; + } + } + return false; + } + private ImFunction enclosingFunction(Element e) { Element cur = e; while (cur != null) { @@ -1005,6 +1024,22 @@ private void recordGenericGlobalUse(Element site, ImVar global) { ownersOf(f).add(owner); } + private void indexGenericGlobalUses() { + prog.accept(new Element.DefaultVisitor() { + @Override + public void visit(ImVarAccess access) { + recordGenericGlobalUse(access, access.getVar()); + super.visit(access); + } + + @Override + public void visit(ImVarArrayAccess access) { + recordGenericGlobalUse(access, access.getVar()); + super.visit(access); + } + }); + } + private void dbgMethodsByName(String phase) { Map counts = new HashMap<>(); for (ImMethod m : prog.getMethods()) { @@ -1496,7 +1531,8 @@ private ImFunction specializeFunction(ImFunction f, GenericTypes generics) { rewriteGenerics(newF, generics, typeVars); } - if (genericNewOnly && specializeTupleValueTypes && genericTypesContainTuple(generics)) { + if (genericNewOnly && (needsGlobalSpecialization(f) + || (specializeTupleValueTypes && genericTypesContainTuple(generics)))) { ImClass owner = classOwning(f); if (owner != null && !owner.getTypeVariables().isEmpty()) { GenericTypes ownerGenerics = generics.take(owner.getTypeVariables().size()); @@ -1624,7 +1660,8 @@ private ImFunction specializeClassFunction(ImFunction function, ImClass owningCl newImplementation.getTypeVariables().removeAll(); newImplementation.setName(function.getName() + "_specialized"); rewriteGenerics(newImplementation, generics, typeVariables); - if (specializeTupleValueTypes && genericTypesContainTuple(generics)) { + if (needsGlobalSpecialization(function) + || (specializeTupleValueTypes && genericTypesContainTuple(generics))) { GenericTypes ownerGenerics = generics.take(owningClass.getTypeVariables().size()); specializeClass(owningClass, ownerGenerics); rewriteOwnedGenericGlobals(newImplementation, owningClass, ownerGenerics); @@ -1935,7 +1972,8 @@ private ImClass specializeClass(ImClass c, GenericTypes generics) { // NEW: Create specialized global variables for this class instantiation createSpecializedGlobals(c, generics, typeVars); - if (specializeTupleValueTypes && genericTypesContainTuple(generics)) { + if (genericNewOnly && (classOwnsGenericGlobals(c) + || (specializeTupleValueTypes && genericTypesContainTuple(generics)))) { rewriteOwnedGenericGlobals(newC, c, generics); } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 97902de72..2c699a798 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -870,7 +870,7 @@ public void tupleSpecializedTypedLocalDoesNotRootErasedInitializer() throws IOEx } @Test - public void tupleSpecializedStaticKeepsLiveErasedInitializer() { + public void tupleSpecializedStaticKeepsAllLiveInitializers() { test().testLua(true).executeProg().lines( "package Test", "native testSuccess()", @@ -884,7 +884,7 @@ public void tupleSpecializedStaticKeepsLiveErasedInitializer() { " static function get() returns int", " return value", "init", - " if Box.get() == 1 and Box.get() == 2 and bumps == 2", + " if Box.get() + Box.get() == 3 and Box.get() != Box.get() and bumps == 2", " testSuccess()" ); } @@ -955,11 +955,12 @@ public void nestedConcreteGenericStaticStorageCompilesInLua() throws IOException " this.value = value", "init", " let nested = new List>()", - " nested.add(new List())", - " nested.get(0).add(new Item(7))", + " let inner = new List()", + " nested.add(inner)", + " inner.add(new Item(7))", " let pairs = new List()", " pairs.add(pair(2, 3))", - " if nested.get(0).get(0).value == 7 and pairs.get(0).x == 2", + " if nested.get(0) == inner and inner.get(0).value == 7 and pairs.get(0).x == 2", " testSuccess()" ); @@ -971,10 +972,40 @@ public void nestedConcreteGenericStaticStorageCompilesInLua() throws IOException while (storageDeclarations.find()) { storageNames.add(storageDeclarations.group(1)); } - assertEquals("one erased class-like and one tuple-specialized storage slot are expected", - 2, storageNames.size()); + assertEquals("each concrete List instantiation needs independent static storage", + 3, storageNames.size()); assertEquals("each structural List specialization must emit one storage slot", - 2L, storageNames.stream().distinct().count()); + 3L, storageNames.stream().distinct().count()); + } + + @Test + public void genericStaticsAreIndependentWithoutTupleInstantiation() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "class Slot", + " static T value", + " static function set(T newValue)", + " value = newValue", + " static function get() returns T", + " return value", + "init", + " Slot.set(7)", + " Slot.set(\"ok\")", + " if Slot.get() == 7 and Slot.get() == \"ok\"", + " testSuccess()" + ); + + String compiled = compiledLua("genericStaticsAreIndependentWithoutTupleInstantiation"); + java.util.regex.Matcher storageDeclarations = java.util.regex.Pattern + .compile("(?m)^Slot_value_\\S* = nil$") + .matcher(compiled); + int storages = 0; + while (storageDeclarations.find()) { + storages++; + } + assertEquals("each concrete Slot instantiation needs its own static", + 2, storages); } @Test @@ -1216,7 +1247,9 @@ public void compiletimeGenericArrayReplayLeavesAreSplit() { .matcher(compiled); int persistedAssignments = 0; while (replayBody.find()) { - int assignmentsInFunction = countOccurrences(replayBody.group(1), "Box_store["); + int assignmentsInFunction = (int) java.util.regex.Pattern + .compile("Box_store[^\\[]*\\[") + .matcher(replayBody.group(1)).results().count(); assertTrue("each generic replay leaf must honor the configured split limit:\n" + replayBody.group(), assignmentsInFunction <= 1); persistedAssignments += assignmentsInFunction; From d181a1458971dfc31178ded0e291fe24e3712283 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 16:37:44 +0200 Subject: [PATCH 04/11] Track erased generic static ownership --- .../imtranslation/EliminateGenerics.java | 31 ++++++++++++++++--- .../imtranslation/ImTranslator.java | 26 ++++++++++++++++ .../lua/translation/RemoveGarbage.java | 4 ++- .../tests/LuaBackendAuditTests.java | 21 +++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index 821bbb0a2..86ced55bc 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -465,6 +465,7 @@ private void collectGenericNewUse(ImFunctionCall call) { } return; } + recordErasedConstructorAllocation(call); if (!call.getTypeArguments().isEmpty() && (shouldSpecializeTupleArguments(call.getTypeArguments()) || needsRuntimeTypeSpecialization(call) @@ -479,6 +480,23 @@ private void collectGenericNewUse(ImFunctionCall call) { } } + private void recordErasedConstructorAllocation(ImFunctionCall call) { + if (call.getTypeArguments().isEmpty() + || typeArgumentsContainTypeVariable(call.getTypeArguments()) + || !(call.getFunc().getTrace() instanceof ConstructorDef) + || !(call.getFunc().getReturnType() instanceof ImClassType) + || shouldSpecializeTupleArguments(call.getTypeArguments()) + || needsRuntimeTypeSpecialization(call)) { + return; + } + ImClass owner = classOwning(call.getFunc()); + if (owner != null && classOwnsGenericGlobals(owner) + && !functionNeedsSpecialization(call.getFunc(), + Collections.newSetFromMap(new IdentityHashMap<>()))) { + translator.recordErasedGenericAllocation(owner, call.getTypeArguments()); + } + } + /** * Collects a call which names a function of a generic class outright, taking the instantiation * from the receiver it was handed. @@ -571,10 +589,15 @@ private void collectCallThroughGenericReceiver(ImFunctionCall call) { private void collectGenericNewUse(ImAlloc alloc) { ImClassType clazz = alloc.getClazz(); if (clazz.getTypeArguments().isEmpty() - || typeArgumentsContainTypeVariable(clazz.getTypeArguments()) - || (!shouldSpecializeTupleArguments(clazz.getTypeArguments()) - && !needsRuntimeTypeSpecialization(clazz) - && !isConstructionOnlyInstantiation(clazz.getClassDef()))) { + || typeArgumentsContainTypeVariable(clazz.getTypeArguments())) { + return; + } + if (!shouldSpecializeTupleArguments(clazz.getTypeArguments()) + && !needsRuntimeTypeSpecialization(clazz) + && !isConstructionOnlyInstantiation(clazz.getClassDef())) { + if (classOwnsGenericGlobals(clazz.getClassDef())) { + translator.recordErasedGenericAllocation(clazz.getClassDef(), clazz.getTypeArguments()); + } return; } genericsUses.add(new GenericClazzUse(alloc)); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ImTranslator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ImTranslator.java index d5feebe23..454436cdc 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ImTranslator.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ImTranslator.java @@ -61,6 +61,7 @@ public record Specialisation(Element original, List typeArgument } private final Map specialisations = new IdentityHashMap<>(); + private final Map> erasedGenericAllocations = new IdentityHashMap<>(); /** * @param typeArguments the arguments the copy was made for, empty when a copy carries none of its @@ -99,6 +100,31 @@ public void recordGenericStaticOwner(ImVar global, ImClass owner) { return specialisations.get(copy); } + public void recordErasedGenericAllocation(ImClass clazz, List typeArguments) { + erasedGenericAllocations.computeIfAbsent(canonical(clazz), ignored -> new HashSet<>()) + .add(new GenericTypes(typeArguments)); + } + + public boolean hasErasedAllocationWithoutStaticSpecialization(ImClass clazz, ImVar originalStatic) { + Set allocations = erasedGenericAllocations.get(canonical(clazz)); + if (allocations == null || allocations.isEmpty()) { + return false; + } + Set specializedStatics = new HashSet<>(); + for (Map.Entry entry : specialisations.entrySet()) { + Specialisation specialization = entry.getValue(); + if (specialization.original() == originalStatic) { + specializedStatics.add(new GenericTypes(specialization.typeArguments())); + } + } + for (GenericTypes allocation : allocations) { + if (!specializedStatics.contains(allocation)) { + return true; + } + } + return false; + } + /** * The node {@code copy} was ultimately copied from, or {@code copy} itself. *

diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/RemoveGarbage.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/RemoveGarbage.java index f2951044e..f8b4823cb 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/RemoveGarbage.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/RemoveGarbage.java @@ -190,7 +190,9 @@ public static void removePhantomGenericStaticInitializers(ImProg prog, ImTransla changed = false; for (ImVar original : candidates.keySet()) { ImClass owner = translator.genericStaticOwnerOf(original); - if ((used.getVars().contains(original) || used.getInstantiatedClasses().contains(owner)) + boolean erasedInstantiationNeedsOriginal = used.getInstantiatedClasses().contains(owner) + && translator.hasErasedAllocationWithoutStaticSpecialization(owner, original); + if ((used.getVars().contains(original) || erasedInstantiationNeedsOriginal) && liveOriginals.add(original)) { changed = true; } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 2c699a798..045ffe007 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -1008,6 +1008,27 @@ public void genericStaticsAreIndependentWithoutTupleInstantiation() throws IOExc 2, storages); } + @Test + public void constructedErasedInstantiationDoesNotDuplicateSpecializedStaticInitializer() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " construct()", + " static function get() returns int", + " return value", + "init", + " new Box()", + " if Box.get() == 1 and bumps == 1", + " testSuccess()" + ); + } + @Test public void randomizedNestedGenericTupleClassShapesMatchAllBackends() { record Shape(String type, String value, int constructions) {} From 94adc348214fdd20cbf88ca3e1e0311dfb835509 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 16:51:09 +0200 Subject: [PATCH 05/11] Materialize statics for erased instantiations --- .../imtranslation/EliminateGenerics.java | 16 ++++++++++++-- .../tests/LuaBackendAuditTests.java | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index 86ced55bc..b8e4c7d57 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -40,6 +40,8 @@ public class EliminateGenerics { * has them re-derived from its receiver, which would collect and specialise it again forever. */ private final Set specializedCallSites = Collections.newSetFromMap(new IdentityHashMap<>()); + private final Set recordedErasedStaticAllocations = + Collections.newSetFromMap(new IdentityHashMap<>()); private final Table specializedFunctions = HashBasedTable.create(); /** The class each function was moved out of, for calls which name their target without a receiver. */ private final Map functionOwners = new IdentityHashMap<>(); @@ -493,8 +495,18 @@ private void recordErasedConstructorAllocation(ImFunctionCall call) { if (owner != null && classOwnsGenericGlobals(owner) && !functionNeedsSpecialization(call.getFunc(), Collections.newSetFromMap(new IdentityHashMap<>()))) { - translator.recordErasedGenericAllocation(owner, call.getTypeArguments()); + recordErasedStaticInstantiation(call, owner, call.getTypeArguments()); + } + } + + private void recordErasedStaticInstantiation(Element site, ImClass owner, + List typeArguments) { + if (!recordedErasedStaticAllocations.add(site)) { + return; } + GenericTypes generics = new GenericTypes(typeArguments); + translator.recordErasedGenericAllocation(owner, typeArguments); + genericsUses.add(() -> specializeClass(owner, generics)); } /** @@ -596,7 +608,7 @@ private void collectGenericNewUse(ImAlloc alloc) { && !needsRuntimeTypeSpecialization(clazz) && !isConstructionOnlyInstantiation(clazz.getClassDef())) { if (classOwnsGenericGlobals(clazz.getClassDef())) { - translator.recordErasedGenericAllocation(clazz.getClassDef(), clazz.getTypeArguments()); + recordErasedStaticInstantiation(alloc, clazz.getClassDef(), clazz.getTypeArguments()); } return; } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 045ffe007..371db0521 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -1029,6 +1029,28 @@ public void constructedErasedInstantiationDoesNotDuplicateSpecializedStaticIniti ); } + @Test + public void eachConstructedErasedInstantiationGetsItsOwnStaticInitializer() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " construct()", + " static function get() returns int", + " return value", + "init", + " new Box()", + " new Box()", + " if Box.get() >= 1 and bumps == 3", + " testSuccess()" + ); + } + @Test public void randomizedNestedGenericTupleClassShapesMatchAllBackends() { record Shape(String type, String value, int constructions) {} From 48c2f773ade9e8885608d5f9a436ef7b588ae677 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 17:06:49 +0200 Subject: [PATCH 06/11] Preserve generic static initializer order --- .../translation/imtranslation/EliminateGenerics.java | 10 ++++++++-- .../tests/wurstscript/tests/LuaBackendAuditTests.java | 8 +++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index b8e4c7d57..313a08742 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -61,6 +61,8 @@ private record RuntimeTypeUse(ImClass clazz, GenericTypes generics) { // NEW: Track specialized global variables for generic static fields // Key: (original generic global var, concrete type instantiation) -> specialized var private final Table specializedGlobals = HashBasedTable.create(); + /** Last specialized initializer emitted for each original initializer, preserving discovery order. */ + private final Map specializedInitializerTails = new IdentityHashMap<>(); // NEW: Track which global vars belong to which generic class // This helps us know which globals need specialization @@ -2188,10 +2190,14 @@ private void createSpecializedGlobals(ImClass originalClass, GenericTypes generi ImLExpr newLeft = specializeLhs.apply(origSet.getLeft()); ImSet specSet = JassIm.ImSet(originalGlobal.attrTrace(), newLeft, rhs); - // schedule insertion right after origSet in its parent ImStmts + // Append after earlier specializations of this initializer. Each invocation of + // createSpecializedGlobals has its own insertion batch; always inserting after + // origSet would therefore reverse specialization discovery/initializer order. + ImStmt insertionPoint = specializedInitializerTails.getOrDefault(origSet, origSet); IdentityHashMap> byStmt = insertsByParent.computeIfAbsent(parentStmts, k -> new IdentityHashMap<>()); - byStmt.computeIfAbsent(origSet, k -> new ArrayList<>(1)).add(specSet); + byStmt.computeIfAbsent(insertionPoint, k -> new ArrayList<>(1)).add(specSet); + specializedInitializerTails.put(origSet, specSet); // keep prog.getGlobalInits consistent, but do NOT reuse the tree-attached node elsewhere specializedInitsForMap.add((ImSet) specSet.copy()); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 371db0521..1517b8077 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -884,7 +884,7 @@ public void tupleSpecializedStaticKeepsAllLiveInitializers() { " static function get() returns int", " return value", "init", - " if Box.get() + Box.get() == 3 and Box.get() != Box.get() and bumps == 2", + " if Box.get() == 1 and Box.get() == 2 and bumps == 2", " testSuccess()" ); } @@ -902,10 +902,12 @@ public void tupleSpecializedStaticKeepsInitializerForConstructedErasedClass() { "class Box", " static int value = bump()", " construct()", + " static function get() returns int", + " return value", "init", " new Box()", " new Box()", - " if bumps == 2", + " if Box.get() == 1 and Box.get() == 2 and bumps == 2", " testSuccess()" ); } @@ -1046,7 +1048,7 @@ public void eachConstructedErasedInstantiationGetsItsOwnStaticInitializer() { "init", " new Box()", " new Box()", - " if Box.get() >= 1 and bumps == 3", + " if Box.get() == 1 and Box.get() == 2 and Box.get() == 3 and bumps == 3", " testSuccess()" ); } From 9861d7a0a96e1697f31717f37f349a376f83f2ce Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 17:20:27 +0200 Subject: [PATCH 07/11] Specialize static-owning generic factories --- .../imtranslation/EliminateGenerics.java | 31 ++++++++++++-- .../tests/LuaBackendAuditTests.java | 40 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index 313a08742..d95032645 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -807,9 +807,10 @@ private boolean functionNeedsSpecialization(ImFunction function, Set /** * Whether a function must be specialised even on Lua, which otherwise keeps generics erased. *

- * Two operations need the concrete type argument: constructing a value of it, and dispatching - * on a type class bound. Specialising these paths keeps a bounded generic as cheap on Lua as it - * is on Jass, at the cost of one copy per instantiation actually used. + * Concrete type arguments are needed when constructing a value of them, dispatching on a type + * class bound, or constructing a generic class whose static storage is per instantiation. + * Specialising these paths keeps a bounded generic as cheap on Lua as it is on Jass, at the cost + * of one copy per instantiation actually used. */ private boolean functionNeedsSpecialization(ImFunction function, Set visitedFunctions, Set visitedMethods) { @@ -849,7 +850,8 @@ public void visit(ImAlloc alloc) { @Override public void visit(ImFunctionCall call) { - if (translator.isGenericNewMarker(call.getFunc()) + if (constructsClassOwningGenericGlobals(function, call) + || translator.isGenericNewMarker(call.getFunc()) || functionNeedsSpecialization(call.getFunc(), visitedFunctions, visitedMethods)) { found[0] = true; return; @@ -869,6 +871,27 @@ public void visit(ImMethodCall call) { return found[0]; } + /** + * A generic caller containing {@code new Box()} must be revisited after {@code T} becomes + * concrete so each constructed instantiation can register its own static storage. Detect the + * constructor call at the caller boundary; marking the constructor implementation itself would + * unnecessarily redirect ordinary objects away from Lua's erased representation. + */ + private boolean constructsClassOwningGenericGlobals(ImFunction enclosingFunction, + ImFunctionCall call) { + if (!(call.getFunc().getTrace() instanceof ConstructorDef)) { + return false; + } + // A lowered constructor wrapper calls the class initializer carrying the same source + // ConstructorDef. That call implements the current allocation; it is not another generic + // allocation hidden inside this function and direct callers register it themselves. + if (enclosingFunction.getTrace() == call.getFunc().getTrace()) { + return false; + } + ImClass owner = classOwning(call.getFunc()); + return owner != null && classOwnsGenericGlobals(owner); + } + private boolean methodNeedsSpecialization(ImMethod method, Set visitedFunctions, Set visitedMethods) { if (!visitedMethods.add(method)) { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 1517b8077..5449f45e3 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -1053,6 +1053,46 @@ public void eachConstructedErasedInstantiationGetsItsOwnStaticInitializer() { ); } + @Test + public void genericFactoryAllocationSpecializesStaticOwningClass() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " construct()", + " static function get() returns int", + " return value", + "function make() returns Box", + " return new Box()", + "function forward() returns Box", + " return make()", + "class Maker", + " construct()", + " function makeBox() returns Box", + " return new Box()", + "init", + " let first = forward()", + " let second = forward()", + " let third = new Maker().makeBox()", + " if first != null and second != null and third != null", + " and Box.get() == 1 and Box.get() == 2", + " and Box.get() == 3 and bumps == 3", + " testSuccess()" + ); + + String compiled = compiledLua("genericFactoryAllocationSpecializesStaticOwningClass"); + assertEquals("the shared constructor must allocate ordinary objects on the erased Lua class", + 1, countOccurrences(compiled, "= Box:create()")); + assertFalse("static specialization must not create specialized object classes", + java.util.regex.Pattern.compile("(?m)^Box_specialized\\S* = \\(\\{\\}\\)$") + .matcher(compiled).find()); + } + @Test public void randomizedNestedGenericTupleClassShapesMatchAllBackends() { record Shape(String type, String value, int constructions) {} From bd05ff7ae18b2d55e13a8fc2a9014f996e067012 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 17:35:51 +0200 Subject: [PATCH 08/11] Handle inherited generic static storage --- .../imtranslation/EliminateGenerics.java | 45 ++++++++++- .../tests/LuaBackendAuditTests.java | 79 +++++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index d95032645..b867cc4ce 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -879,7 +879,8 @@ public void visit(ImMethodCall call) { */ private boolean constructsClassOwningGenericGlobals(ImFunction enclosingFunction, ImFunctionCall call) { - if (!(call.getFunc().getTrace() instanceof ConstructorDef)) { + if (!(call.getFunc().getTrace() instanceof ConstructorDef) + || !typeArgumentsContainTypeVariable(call.getTypeArguments())) { return false; } // A lowered constructor wrapper calls the class initializer carrying the same source @@ -1058,12 +1059,25 @@ private boolean needsGlobalSpecialization(ImFunction f) { } private boolean classOwnsGenericGlobals(ImClass clazz) { + return classOwnsGenericGlobals(clazz, + Collections.newSetFromMap(new IdentityHashMap<>())); + } + + private boolean classOwnsGenericGlobals(ImClass clazz, Set visited) { + if (!visited.add(clazz)) { + return false; + } ImClass canonical = translator.canonical(clazz); for (ImClass owner : globalToClass.values()) { if (translator.canonical(owner) == canonical) { return true; } } + for (ImClassType superClass : clazz.getSuperClasses()) { + if (classOwnsGenericGlobals(superClass.getClassDef(), visited)) { + return true; + } + } return false; } @@ -1630,16 +1644,39 @@ public void visit(ImVarArrayAccess access) { private ImVar specializedGlobal(ImVar original) { ImClass globalOwner = globalToClass.get(original); - if (globalOwner == null - || translator.canonical(globalOwner) != translator.canonical(owner)) { + if (globalOwner == null) { return original; } - ImVar result = ensureSpecializedGlobal(original, globalOwner, ownerGenerics); + GenericTypes globalGenerics = adaptGenericsToOwner(owner, ownerGenerics, globalOwner); + if (globalGenerics == null) { + return original; + } + ImVar result = ensureSpecializedGlobal(original, globalOwner, globalGenerics); return result == null ? original : result; } }); } + /** Maps a concrete subclass instantiation onto the type arguments of a static's declaring class. */ + private @Nullable GenericTypes adaptGenericsToOwner(ImClass concreteOwner, + GenericTypes concreteGenerics, + ImClass declaringOwner) { + if (translator.canonical(concreteOwner) == translator.canonical(declaringOwner)) { + return concreteGenerics; + } + ImTypeArguments arguments = JassIm.ImTypeArguments(); + for (ImTypeArgument argument : concreteGenerics.getTypeArguments()) { + arguments.add(argument.copy()); + } + ImClassType adapted = adaptToSuperclass( + JassIm.ImClassType(concreteOwner, arguments), declaringOwner); + if (adapted == null || adapted.getTypeArguments().size() != declaringOwner.getTypeVariables().size() + || typeArgumentsContainTypeVariable(adapted.getTypeArguments())) { + return null; + } + return new GenericTypes(adapted.getTypeArguments()); + } + /** * creates a specialized version of this method */ diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 5449f45e3..34a9afe50 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -1093,6 +1093,85 @@ public void genericFactoryAllocationSpecializesStaticOwningClass() throws IOExce .matcher(compiled).find()); } + @Test + public void inheritedGenericStaticUsesDeclaringOwnerSpecialization() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "class Base", + " static T value", + "class Child extends Base", + " construct()", + " function set(T newValue)", + " value = newValue", + " function get() returns T", + " return value", + "init", + " let ints = new Child()", + " let strings = new Child()", + " ints.set(7)", + " strings.set(\"ok\")", + " if ints.get() == 7 and strings.get() == \"ok\"", + " testSuccess()" + ); + + String compiled = compiledLua("inheritedGenericStaticUsesDeclaringOwnerSpecialization"); + java.util.regex.Matcher declarations = java.util.regex.Pattern + .compile("(?m)^Base_value_\\S* = nil$").matcher(compiled); + int storages = 0; + while (declarations.find()) { + storages++; + } + assertEquals("each inherited Base static needs independent storage", 2, storages); + } + + @Test + public void constructedSubclassInitializesInheritedGenericStatic() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Base", + " static int value = bump()", + "class Child extends Base", + " construct()", + "init", + " new Child()", + " new Child()", + " if bumps == 2", + " testSuccess()" + ); + } + + @Test + public void fixedConcreteConstructionDoesNotSpecializeUnrelatedGenericCaller() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " construct()", + "function helper() returns Box", + " return new Box()", + "init", + " let first = helper()", + " let second = helper()", + " if first != null and second != null and bumps == 1", + " testSuccess()" + ); + + String compiled = compiledLua("fixedConcreteConstructionDoesNotSpecializeUnrelatedGenericCaller"); + assertFalse("fixed Box construction must not clone helper", + compiled.contains("helper_specialized")); + } + @Test public void randomizedNestedGenericTupleClassShapesMatchAllBackends() { record Shape(String type, String value, int constructions) {} From 408e1c93291407d6e6f21b68b083293e357ddef5 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 17:50:40 +0200 Subject: [PATCH 09/11] Register erased generic helper statics --- .../imtranslation/EliminateGenerics.java | 30 +++++++++++++++++-- .../tests/LuaBackendAuditTests.java | 26 +++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index b867cc4ce..c9e56fcb8 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -481,9 +481,35 @@ private void collectGenericNewUse(ImFunctionCall call) { } if (call.getTypeArguments().isEmpty()) { collectCallThroughGenericReceiver(call); + } else if (!typeArgumentsContainTypeVariable(call.getTypeArguments()) + && !(call.getFunc().getTrace() instanceof ConstructorDef)) { + // The generic callee remains erased, so its body is skipped by collectGenericNewRoots. + // Fixed concrete allocations inside it still name real per-instantiation statics and + // must be registered without cloning the caller for unrelated type arguments. + recordFixedErasedStaticAllocations(call.getFunc(), + Collections.newSetFromMap(new IdentityHashMap<>())); } } + private void recordFixedErasedStaticAllocations(ImFunction function, + Set visited) { + if (!visited.add(function)) { + return; + } + function.accept(new Element.DefaultVisitor() { + @Override + public void visit(ImFunctionCall nestedCall) { + super.visit(nestedCall); + recordErasedConstructorAllocation(nestedCall); + if (!nestedCall.getTypeArguments().isEmpty() + && !typeArgumentsContainTypeVariable(nestedCall.getTypeArguments()) + && !(nestedCall.getFunc().getTrace() instanceof ConstructorDef)) { + recordFixedErasedStaticAllocations(nestedCall.getFunc(), visited); + } + } + }); + } + private void recordErasedConstructorAllocation(ImFunctionCall call) { if (call.getTypeArguments().isEmpty() || typeArgumentsContainTypeVariable(call.getTypeArguments()) @@ -2133,7 +2159,7 @@ private ImExpr rewriteGenericGlobalsInExpr(ImExpr e, ImClass owningClass, Generi ImClass owner = globalToClass.get(v); if (owner == null) return; - GenericTypes g = normalizeToClassArity(generics, owner, "init-rhs"); + GenericTypes g = adaptGenericsToOwner(owningClass, generics, owner); if (g == null || g.containsTypeVariable()) return; ImVar sg = ensureSpecializedGlobal(v, owner, g); @@ -2146,7 +2172,7 @@ private ImExpr rewriteGenericGlobalsInExpr(ImExpr e, ImClass owningClass, Generi ImClass owner = globalToClass.get(v); if (owner == null) return; - GenericTypes g = normalizeToClassArity(generics, owner, "init-rhs"); + GenericTypes g = adaptGenericsToOwner(owningClass, generics, owner); if (g == null || g.containsTypeVariable()) return; ImVar sg = ensureSpecializedGlobal(v, owner, g); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index 34a9afe50..b8d005e2a 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -1158,12 +1158,14 @@ public void fixedConcreteConstructionDoesNotSpecializeUnrelatedGenericCaller() t "class Box", " static int value = bump()", " construct()", + " static function get() returns int", + " return value", "function helper() returns Box", " return new Box()", "init", " let first = helper()", " let second = helper()", - " if first != null and second != null and bumps == 1", + " if first != null and second != null and Box.get() == 2 and bumps == 2", " testSuccess()" ); @@ -1172,6 +1174,28 @@ public void fixedConcreteConstructionDoesNotSpecializeUnrelatedGenericCaller() t compiled.contains("helper_specialized")); } + @Test + public void inheritedGenericStaticInitializerUsesDeclaringOwnerMapping() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Base", + " static int serial = bump()", + "class Child extends Base", + " static int copied = serial", + " static function get() returns int", + " return copied", + "init", + " if Child.get() == 1 and Child.get() == 2", + " and bumps == 2", + " testSuccess()" + ); + } + @Test public void randomizedNestedGenericTupleClassShapesMatchAllBackends() { record Shape(String type, String value, int constructions) {} From cdc2a9011a588a8dac324e2118f8956d1291c8ba Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 18:06:05 +0200 Subject: [PATCH 10/11] Collect fixed generic method operations --- .../imtranslation/EliminateGenerics.java | 53 +++++++++-------- .../tests/LuaBackendAuditTests.java | 57 +++++++++++++++++++ 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index c9e56fcb8..3caed1352 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -42,6 +42,8 @@ public class EliminateGenerics { private final Set specializedCallSites = Collections.newSetFromMap(new IdentityHashMap<>()); private final Set recordedErasedStaticAllocations = Collections.newSetFromMap(new IdentityHashMap<>()); + private final Set scannedFixedStaticCallees = + Collections.newSetFromMap(new IdentityHashMap<>()); private final Table specializedFunctions = HashBasedTable.create(); /** The class each function was moved out of, for calls which name their target without a receiver. */ private final Map functionOwners = new IdentityHashMap<>(); @@ -486,26 +488,25 @@ private void collectGenericNewUse(ImFunctionCall call) { // The generic callee remains erased, so its body is skipped by collectGenericNewRoots. // Fixed concrete allocations inside it still name real per-instantiation statics and // must be registered without cloning the caller for unrelated type arguments. - recordFixedErasedStaticAllocations(call.getFunc(), - Collections.newSetFromMap(new IdentityHashMap<>())); + recordFixedErasedStaticAllocations(call.getFunc()); } } - private void recordFixedErasedStaticAllocations(ImFunction function, - Set visited) { - if (!visited.add(function)) { + private void recordFixedErasedStaticAllocations(ImFunction function) { + if (!scannedFixedStaticCallees.add(function)) { return; } function.accept(new Element.DefaultVisitor() { @Override public void visit(ImFunctionCall nestedCall) { super.visit(nestedCall); - recordErasedConstructorAllocation(nestedCall); - if (!nestedCall.getTypeArguments().isEmpty() - && !typeArgumentsContainTypeVariable(nestedCall.getTypeArguments()) - && !(nestedCall.getFunc().getTrace() instanceof ConstructorDef)) { - recordFixedErasedStaticAllocations(nestedCall.getFunc(), visited); - } + collectGenericNewUse(nestedCall); + } + + @Override + public void visit(ImMethodCall nestedCall) { + super.visit(nestedCall); + collectGenericNewUse(nestedCall); } }); } @@ -744,21 +745,25 @@ private void collectGenericNewUse(ImMethodCall call) { specializedCallSites.add(call); return; } - if (!shouldSpecializeTupleArguments(call.getTypeArguments()) - && !methodNeedsSpecialization(method, - Collections.newSetFromMap(new IdentityHashMap<>()), - Collections.newSetFromMap(new IdentityHashMap<>()))) { - return; - } if (isMissingClassTypeArguments(call, method)) { addMemberTypeArguments(call, method.attrClass()); } if (typeArgumentsContainTypeVariable(call.getTypeArguments())) { - // The receiver's declared type is still generic, which happens when the method is - // called straight on a freshly constructed value. The construction states the - // instantiation, so take the arguments from it. + // A call directly on a fresh generic construction gets its concrete class arguments + // from that construction before deciding between specialization and fixed-body scan. useConstructionTypeArguments(call); } + boolean needsSpecialization = methodNeedsSpecialization(method, + Collections.newSetFromMap(new IdentityHashMap<>()), + Collections.newSetFromMap(new IdentityHashMap<>())); + if (!shouldSpecializeTupleArguments(call.getTypeArguments()) && !needsSpecialization) { + if (!call.getTypeArguments().isEmpty() + && !typeArgumentsContainTypeVariable(call.getTypeArguments()) + && method.getImplementation() != null) { + recordFixedErasedStaticAllocations(method.getImplementation()); + } + return; + } if (!call.getTypeArguments().isEmpty() && !typeArgumentsContainTypeVariable(call.getTypeArguments())) { genericsUses.add(new GenericMethodCall(call)); @@ -876,9 +881,10 @@ public void visit(ImAlloc alloc) { @Override public void visit(ImFunctionCall call) { + boolean dependsOnTypeVariable = typeArgumentsContainTypeVariable(call.getTypeArguments()); if (constructsClassOwningGenericGlobals(function, call) - || translator.isGenericNewMarker(call.getFunc()) - || functionNeedsSpecialization(call.getFunc(), visitedFunctions, visitedMethods)) { + || (dependsOnTypeVariable && (translator.isGenericNewMarker(call.getFunc()) + || functionNeedsSpecialization(call.getFunc(), visitedFunctions, visitedMethods)))) { found[0] = true; return; } @@ -887,7 +893,8 @@ public void visit(ImFunctionCall call) { @Override public void visit(ImMethodCall call) { - if (methodNeedsSpecialization(call.getMethod(), visitedFunctions, visitedMethods)) { + if (typeArgumentsContainTypeVariable(call.getTypeArguments()) + && methodNeedsSpecialization(call.getMethod(), visitedFunctions, visitedMethods)) { found[0] = true; return; } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java index b8d005e2a..18eedcfca 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java @@ -1196,6 +1196,63 @@ public void inheritedGenericStaticInitializerUsesDeclaringOwnerMapping() { ); } + @Test + public void fixedAllocationInsideErasedGenericMethodIsRegistered() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " construct()", + " static function get() returns int", + " return value", + "class Factory", + " construct()", + " function make() returns Box", + " return new Box()", + "init", + " let factory = new Factory()", + " let made = factory.make()", + " let fresh = new Factory().make()", + " if made != null and fresh != null and Box.get() == 2 and bumps == 2", + " testSuccess()" + ); + + String compiled = compiledLua("fixedAllocationInsideErasedGenericMethodIsRegistered"); + assertFalse("fixed generic method body must not be cloned for its class argument", + compiled.contains("Factory_make_specialized")); + } + + @Test + public void fixedStaticCalleeDoesNotSpecializeUnrelatedGenericCaller() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " static function get() returns int", + " return value", + "function helper() returns int", + " return Box.get()", + "init", + " if helper() == 1 and helper() == 1", + " and Box.get() == 2 and bumps == 2", + " testSuccess()" + ); + + String compiled = compiledLua("fixedStaticCalleeDoesNotSpecializeUnrelatedGenericCaller"); + assertFalse("fixed Box static call must not clone helper", + compiled.contains("helper_specialized")); + } + @Test public void randomizedNestedGenericTupleClassShapesMatchAllBackends() { record Shape(String type, String value, int constructions) {} From 5d41865853f0bbf351f41c5ed36c53853d1c9014 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 18:21:53 +0200 Subject: [PATCH 11/11] Preserve implicit generic reachability --- .../imtranslation/EliminateGenerics.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index 3caed1352..0b04c2f54 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -881,10 +881,14 @@ public void visit(ImAlloc alloc) { @Override public void visit(ImFunctionCall call) { - boolean dependsOnTypeVariable = typeArgumentsContainTypeVariable(call.getTypeArguments()); + // Empty arguments may be supplied implicitly by the enclosing generic receiver. + // Only an explicit, already-concrete call is independent of the caller context. + boolean dependsOnCaller = call.getTypeArguments().isEmpty() + || typeArgumentsContainTypeVariable(call.getTypeArguments()); if (constructsClassOwningGenericGlobals(function, call) - || (dependsOnTypeVariable && (translator.isGenericNewMarker(call.getFunc()) - || functionNeedsSpecialization(call.getFunc(), visitedFunctions, visitedMethods)))) { + || translator.isGenericNewMarker(call.getFunc()) + || (dependsOnCaller + && functionNeedsSpecialization(call.getFunc(), visitedFunctions, visitedMethods))) { found[0] = true; return; } @@ -893,7 +897,9 @@ public void visit(ImFunctionCall call) { @Override public void visit(ImMethodCall call) { - if (typeArgumentsContainTypeVariable(call.getTypeArguments()) + boolean dependsOnCaller = call.getTypeArguments().isEmpty() + || typeArgumentsContainTypeVariable(call.getTypeArguments()); + if (dependsOnCaller && methodNeedsSpecialization(call.getMethod(), visitedFunctions, visitedMethods)) { found[0] = true; return;