From 4b57b7472a65027aefa3d6b3f70def71267a7a13 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 11:45:21 +0200 Subject: [PATCH 01/12] Scalarize Lua tuples through ordered bundles --- AGENTS.md | 14 + .../peeeq/wurstio/WurstCompilerJassImpl.java | 32 +- .../imtranslation/EliminateGenerics.java | 368 ++++++---- .../imtranslation/EliminateTuples.java | 685 ++++++++++++++---- .../imtranslation/ImTranslator.java | 26 + .../lua/translation/LuaTranslator.java | 14 +- .../lua/translation/RemoveGarbage.java | 4 + .../tests/LuaBackendAuditTests.java | 530 +++++++++++++- 8 files changed, 1351 insertions(+), 322 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 54284409f..16786cf9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,20 @@ For config and run-pipeline changes, prefer these focused checks before broader Recent fixes established additional rules for backend work. Follow these for all future changes: +### Compiler phase ownership and lowering invariants + +* Fix malformed or underspecified IR in the phase which creates it. Do not add downstream recovery, + name parsing, or backend-specific guessing for information an earlier phase discarded. +* Semantic identity and specialization keys must use referenced AST/IM nodes plus structural type + arguments, never generated names or string comparison. +* Each lowering phase has one explicit input/output contract. After an abstraction is lowered, + downstream phases consume the lowered representation and must not reconstruct its source meaning. +* Prefer backend-appropriate, state-of-the-art lowering when semantics permit it. Jass limitations may + require compatibility compromises; do not carry those compromises into Lua without evidence. +* Behavioral correctness is the primary requirement. Runtime and allocation performance are the next + requirement: common optimized paths must not retain avoidable compiler-introduced allocation, + dispatch, copying, or bookkeeping overhead. + ### Jass/Lua feature parity * New language/compiler features must be validated for **both Jass and Lua** backends. 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 62a73b79b..107088183 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 @@ -874,12 +874,11 @@ public LuaCompilationUnit transformProgToLua() { ImAttrType.setWurstClassType(null); int stage; - if (containsGenericNewCall() || containsTypeClassDispatch()) { - // Both operations need the concrete type argument, which erasure does not keep. Only - // the paths reaching them are specialised: the full elimination used for Jass is - // followed there by class elimination, and leaves state this backend cannot consume. - beginPhase(2, "Specialize generics for generic construction and type class dispatch"); - new EliminateGenerics(getImTranslator(), getImProg()).transformGenericNewOnly(); + boolean specializeTupleValueTypes = containsTupleTypeArgument(); + if (containsGenericNewCall() || containsTypeClassDispatch() || specializeTupleValueTypes) { + beginPhase(2, "Specialize generics for Lua-only concrete operations"); + new EliminateGenerics(getImTranslator(), getImProg()) + .transformGenericNewOnly(specializeTupleValueTypes); timeTaker.endPhase(); } if (runArgs.isNoDebugMessages()) { @@ -919,6 +918,12 @@ public LuaCompilationUnit transformProgToLua() { getImProg().flatten(imTranslator2); EliminateLocalTypes.eliminateLocalTypesProg(getImProg(), imTranslator2); + timeTaker.beginPhase("eliminate tuples"); + getImProg().flatten(imTranslator2); + EliminateTuples.eliminateTuplesProg(getImProg(), imTranslator2); + imTranslator2.assertProperties(AssertProperty.NOTUPLES); + timeTaker.endPhase(); + optimizer.removeGarbage(); imProg.flatten(imTranslator); timeTaker.endPhase(); @@ -996,4 +1001,19 @@ public void visit(ImTypeVarDispatch dispatch) { }); return found[0]; } + + /** Tuple type arguments need monomorphisation before tuples can become scalar storage. */ + private boolean containsTupleTypeArgument() { + boolean[] found = {false}; + getImProg().accept(new de.peeeq.wurstscript.jassIm.Element.DefaultVisitor() { + @Override + public void visit(ImTypeArgument argument) { + if (TypesHelper.typeContainsTuples(argument.getType())) { + found[0] = true; + } + super.visit(argument); + } + }); + return found[0]; + } } 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 d080df02b..0a64ee6bf 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 @@ -14,6 +14,7 @@ import de.peeeq.wurstscript.translation.imtojass.ImAttrType; import de.peeeq.wurstscript.translation.imtojass.TypeRewriteMatcher; import de.peeeq.wurstscript.translation.lua.translation.RemoveGarbage; +import de.peeeq.wurstscript.types.TypesHelper; import io.vavr.control.Either; import org.eclipse.jdt.annotation.Nullable; import org.jetbrains.annotations.NotNull; @@ -29,6 +30,7 @@ public class EliminateGenerics { private final ImTranslator translator; private final ImProg prog; private boolean genericNewOnly; + private boolean specializeTupleValueTypes; private final Deque genericsUses = new ArrayDeque<>(); /** * Call sites already rewritten to a specialisation. @@ -52,11 +54,7 @@ public class EliminateGenerics { // 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(); - - private static String gKey(GenericTypes g) { - return g.makeName(); - } + private final Table specializedGlobals = HashBasedTable.create(); // NEW: Track which global vars belong to which generic class // This helps us know which globals need specialization @@ -112,12 +110,21 @@ public void transform() { } /** - * Lua normally erases new generics. Generic construction is the one operation which needs the - * concrete type, so only specialize functions on paths leading to {@code wurstNewInstance}. All other - * generic calls and classes keep the Lua backend's normal erased representation. + * Lua normally erases generics. Generic construction and scalar storage for tuple type arguments + * are the operations which need the concrete type, so only specialize paths leading to those + * operations. All other generic calls and classes keep the Lua backend's erased representation. */ public void transformGenericNewOnly() { + transformGenericNewOnly(false); + } + + public void transformGenericNewOnly(boolean specializeTupleValueTypes) { genericNewOnly = true; + this.specializeTupleValueTypes = specializeTupleValueTypes; + if (specializeTupleValueTypes) { + addMemberTypeArguments(); + identifyGenericGlobals(); + } 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 @@ -262,9 +269,43 @@ public void visit(ImMemberAccess memberAccess) { super.visit(memberAccess); collectGenericNewUse(memberAccess); } + + @Override + public void visit(ImDealloc dealloc) { + super.visit(dealloc); + collectGenericNewUse(dealloc); + } + + @Override + public void visit(ImInstanceof instanceOf) { + super.visit(instanceOf); + collectGenericNewUse(instanceOf); + } + + @Override + public void visit(ImTypeIdOfObj typeId) { + super.visit(typeId); + collectGenericNewUse(typeId); + } + + @Override + public void visit(ImTypeIdOfClass typeId) { + super.visit(typeId); + collectGenericNewUse(typeId); + } }); } + private void collectGenericNewUse(ImClassRelatedExprWithClass expression) { + ImClassType clazz = expression.getClazz(); + if (clazz.getTypeArguments().isEmpty() + || typeArgumentsContainTypeVariable(clazz.getTypeArguments()) + || !shouldSpecializeTupleArguments(clazz.getTypeArguments())) { + return; + } + genericsUses.add(new GenericClazzUse(expression)); + } + private void collectGenericNewUses(Element element) { element.accept(new Element.DefaultVisitor() { @Override @@ -304,7 +345,8 @@ private void collectGenericNewUse(ImFunctionCall call) { return; } if (!call.getTypeArguments().isEmpty() - && functionNeedsSpecialization(call.getFunc(), Collections.newSetFromMap(new IdentityHashMap<>()))) { + && (shouldSpecializeTupleArguments(call.getTypeArguments()) + || functionNeedsSpecialization(call.getFunc(), Collections.newSetFromMap(new IdentityHashMap<>())))) { if (!typeArgumentsContainTypeVariable(call.getTypeArguments())) { genericsUses.add(new GenericImFunctionCall(call)); } @@ -347,8 +389,9 @@ private void collectCallThroughGenericReceiver(ImFunctionCall call) { || typeArgumentsContainTypeVariable(classType.getTypeArguments())) { return; } - if (!functionNeedsSpecialization(call.getFunc(), - Collections.newSetFromMap(new IdentityHashMap<>()))) { + if (!shouldSpecializeTupleArguments(classType.getTypeArguments()) + && !functionNeedsSpecialization(call.getFunc(), + Collections.newSetFromMap(new IdentityHashMap<>()))) { return; } genericsUses.add(new GenericClassFunctionCall(call, owningClass, @@ -365,7 +408,11 @@ private void collectCallThroughGenericReceiver(ImFunctionCall call) { private @Nullable ImClass classOwning(ImFunction function) { if (classByFunction == null) { classByFunction = new IdentityHashMap<>(); + Map classBySource = new IdentityHashMap<>(); for (ImClass imClass : prog.getClasses()) { + if (imClass.getTrace() instanceof ClassDef sourceClass) { + classBySource.put(sourceClass, imClass); + } for (ImFunction f : imClass.getFunctions()) { classByFunction.putIfAbsent(f, imClass); } @@ -376,6 +423,16 @@ private void collectCallThroughGenericReceiver(ImFunctionCall call) { } } } + for (ImFunction f : prog.getFunctions()) { + ClassDef sourceClass = f.attrTrace() == null + ? null : f.attrTrace().attrNearestClassDef(); + if (sourceClass != null) { + ImClass owner = classBySource.get(sourceClass); + if (owner != null) { + classByFunction.putIfAbsent(f, owner); + } + } + } } return classByFunction.get(function); } @@ -393,7 +450,8 @@ private void collectGenericNewUse(ImAlloc alloc) { ImClassType clazz = alloc.getClazz(); if (clazz.getTypeArguments().isEmpty() || typeArgumentsContainTypeVariable(clazz.getTypeArguments()) - || !isConstructionOnlyInstantiation(clazz.getClassDef())) { + || (!shouldSpecializeTupleArguments(clazz.getTypeArguments()) + && !isConstructionOnlyInstantiation(clazz.getClassDef()))) { return; } genericsUses.add(new GenericClazzUse(alloc)); @@ -468,7 +526,7 @@ private void collectGenericNewUse(ImMemberAccess memberAccess) { // A class that has already been specialised has nothing left to select, and asking the // receiver to adapt to it fails outright: the receiver is still typed by the generic class // the specialised one was copied from, which is not a superclass of it. - if (owningClass.getTypeVariables().isEmpty() || !isConstructionOnlyInstantiation(owningClass)) { + if (owningClass.getTypeVariables().isEmpty()) { return; } if (memberAccess.getTypeArguments().isEmpty()) { @@ -479,6 +537,10 @@ private void collectGenericNewUse(ImMemberAccess memberAccess) { || typeArgumentsContainTypeVariable(memberAccess.getTypeArguments())) { return; } + if (!shouldSpecializeTupleArguments(memberAccess.getTypeArguments()) + && !isConstructionOnlyInstantiation(owningClass)) { + return; + } genericsUses.add(new GenericMemberAccess(memberAccess)); } @@ -487,7 +549,17 @@ private void collectGenericNewUse(ImMethodCall call) { return; } ImMethod method = call.getMethod(); - if (!methodNeedsSpecialization(method, + ImTranslator.Specialisation existing = translator.specialisationOf(method); + if (existing != null && existing.original() instanceof ImMethod) { + // The owning class created this concrete method. Its type arguments were consumed by + // that structural class specialisation; collecting it again invents a second generic + // boundary with no type variables and loses the phase invariant. + call.getTypeArguments().removeAll(); + specializedCallSites.add(call); + return; + } + if (!shouldSpecializeTupleArguments(call.getTypeArguments()) + && !methodNeedsSpecialization(method, Collections.newSetFromMap(new IdentityHashMap<>()), Collections.newSetFromMap(new IdentityHashMap<>()))) { return; @@ -550,6 +622,23 @@ private boolean typeArgumentsContainTypeVariable(ImTypeArguments typeArguments) return false; } + private boolean typeArgumentsContainTuple(Iterable typeArguments) { + for (ImTypeArgument typeArgument : typeArguments) { + if (TypesHelper.typeContainsTuples(typeArgument.getType())) { + return true; + } + } + return false; + } + + private boolean shouldSpecializeTupleArguments(ImTypeArguments typeArguments) { + return specializeTupleValueTypes && typeArgumentsContainTuple(typeArguments); + } + + private boolean genericTypesContainTuple(GenericTypes generics) { + return typeArgumentsContainTuple(generics.getTypeArguments()); + } + private boolean functionNeedsSpecialization(ImFunction function, Set visited) { return functionNeedsSpecialization(function, visited, Collections.newSetFromMap(new IdentityHashMap<>())); @@ -1092,11 +1181,15 @@ private void moveFunctionsOutOfClass(ImClass c) { * These are the "static" fields that need specialization */ private void identifyGenericGlobals() { - // Only include "relevant" classes: new-generic or subclass of new-generic. - Map relevantClassMap = buildRelevantClassMap(); + Map genericClassesBySource = new IdentityHashMap<>(); + for (ImClass imClass : prog.getClasses()) { + if (!imClass.getTypeVariables().isEmpty() && imClass.getTrace() instanceof ClassDef sourceClass) { + genericClassesBySource.put(sourceClass, imClass); + } + } for (ImVar global : prog.getGlobals()) { - ImClass owner = resolveOwningClassFromTrace(global, relevantClassMap); + ImClass owner = resolveOwningClassFromTrace(global, genericClassesBySource); if (owner == null) { continue; // not defined inside a class (package/global constant, etc.) } @@ -1109,43 +1202,13 @@ private void identifyGenericGlobals() { } } - /** - * Build a map of class-name -> ImClass, but only for "relevant" classes: - * - the class is new-generic (has typeVariables) - * - OR any of its superclasses is new-generic (transitively) - */ - private Map buildRelevantClassMap() { - Map m = new HashMap<>(); - IdentityHashMap memo = new IdentityHashMap<>(); - - for (ImClass c : prog.getClasses()) { - if (!c.getTypeVariables().isEmpty()) { - m.put(c.getName(), c); - } - } - return m; - } - - /** - * Resolve owning class for a global via trace: - * - if the global's trace source is inside a class, return the matching ImClass (if relevant) - * - otherwise return null - */ - private @Nullable ImClass resolveOwningClassFromTrace(ImVar global, Map relevantClassMap) { + /** Resolve a generic static's owner through its source class identity. */ + private @Nullable ImClass resolveOwningClassFromTrace( + ImVar global, Map genericClassesBySource) { if (global.getTrace() == null) return null; - - // This is the only assumption you may need to adapt if your ImTrace API differs: - de.peeeq.wurstscript.ast.Element srcObj = global.getTrace(); // expected to be a wurst AST Element - if (srcObj == null) return null; - - @Nullable ClassDef classDef = srcObj.attrNearestClassDef(); + @Nullable ClassDef classDef = global.getTrace().attrNearestClassDef(); if (classDef == null) return null; - - // Get the class name from the AST (no global-name parsing). - String className = classDef.getNameId().getName(); - - // Only accept if it is one of the relevant classes (new-generic or inherits new-generic). - return relevantClassMap.get(className); + return genericClassesBySource.get(classDef); } /** @@ -1307,6 +1370,15 @@ private ImFunction specializeFunction(ImFunction f, GenericTypes generics) { rewriteGenerics(newF, generics, typeVars); } + if (genericNewOnly && specializeTupleValueTypes && genericTypesContainTuple(generics)) { + ImClass owner = classOwning(f); + if (owner != null && !owner.getTypeVariables().isEmpty()) { + GenericTypes ownerGenerics = generics.take(owner.getTypeVariables().size()); + specializeClass(owner, ownerGenerics); + rewriteOwnedGenericGlobals(newF, owner, ownerGenerics); + } + } + // Fix calls inside this specialized function so they also point to specialized callees if (genericNewOnly) { collectGenericNewUses(newF); @@ -1320,6 +1392,32 @@ private ImFunction specializeFunction(ImFunction f, GenericTypes generics) { return newF; } + private void rewriteOwnedGenericGlobals(Element copy, ImClass owner, GenericTypes ownerGenerics) { + copy.accept(new Element.DefaultVisitor() { + @Override + public void visit(ImVarAccess access) { + super.visit(access); + access.setVar(specializedGlobal(access.getVar())); + } + + @Override + public void visit(ImVarArrayAccess access) { + super.visit(access); + access.setVar(specializedGlobal(access.getVar())); + } + + private ImVar specializedGlobal(ImVar original) { + ImClass globalOwner = globalToClass.get(original); + if (globalOwner == null + || translator.canonical(globalOwner) != translator.canonical(owner)) { + return original; + } + ImVar result = ensureSpecializedGlobal(original, globalOwner, ownerGenerics); + return result == null ? original : result; + } + }); + } + /** * creates a specialized version of this method */ @@ -1386,7 +1484,9 @@ private ImFunction specializeClassFunction(ImFunction function, ImClass owningCl List typeVariables = new ArrayList<>(owningClass.getTypeVariables()); typeVariables.addAll(function.getTypeVariables()); if (typeVariables.size() != generics.getTypeArguments().size()) { - throw new CompileError(blameFor, "Generics should match class method type variables."); + throw new CompileError(blameFor, "Generics should match class method type variables for " + + function.getName() + ": expected " + typeVariables.size() + " but found " + + generics.getTypeArguments().size() + "."); } ImFunction newImplementation = function.copyWithRefs(); @@ -1398,6 +1498,11 @@ private ImFunction specializeClassFunction(ImFunction function, ImClass owningCl newImplementation.getTypeVariables().removeAll(); newImplementation.setName(function.getName() + "_specialized"); rewriteGenerics(newImplementation, generics, typeVariables); + if (specializeTupleValueTypes && genericTypesContainTuple(generics)) { + GenericTypes ownerGenerics = generics.take(owningClass.getTypeVariables().size()); + specializeClass(owningClass, ownerGenerics); + rewriteOwnedGenericGlobals(newImplementation, owningClass, ownerGenerics); + } collectGenericNewUses(newImplementation); return newImplementation; } @@ -1673,6 +1778,19 @@ private ImClass specializeClass(ImClass c, GenericTypes generics) { for (int i = 0; i < c.getFields().size() && i < newC.getFields().size(); i++) { translator.recordSpecialisation(newC.getFields().get(i), c.getFields().get(i), generics.getTypeArguments()); } + for (int i = 0; i < c.getMethods().size() && i < newC.getMethods().size(); i++) { + ImMethod originalMethod = c.getMethods().get(i); + ImMethod copiedMethod = newC.getMethods().get(i); + translator.recordSpecialisation(copiedMethod, originalMethod, generics.getTypeArguments()); + if (originalMethod.getImplementation() != null && copiedMethod.getImplementation() != null) { + translator.recordSpecialisation(copiedMethod.getImplementation(), + originalMethod.getImplementation(), generics.getTypeArguments()); + } + } + for (int i = 0; i < c.getFunctions().size() && i < newC.getFunctions().size(); i++) { + translator.recordSpecialisation(newC.getFunctions().get(i), c.getFunctions().get(i), + generics.getTypeArguments()); + } translator.recordSpecialisation(newC, c, generics.getTypeArguments()); specializedClasses.put(c, generics, newC); prog.getClasses().add(newC); @@ -1688,8 +1806,12 @@ private ImClass specializeClass(ImClass c, GenericTypes generics) { // NEW: Create specialized global variables for this class instantiation createSpecializedGlobals(c, generics, typeVars); + if (specializeTupleValueTypes && genericTypesContainTuple(generics)) { + rewriteOwnedGenericGlobals(newC, c, generics); + } - if (genericNewOnly && isConstructionOnlyInstantiation(c)) { + if (genericNewOnly && (isConstructionOnlyInstantiation(c) + || (specializeTupleValueTypes && genericTypesContainTuple(generics)))) { attachSpecializedClassMethods(c, newC, generics); } @@ -1771,24 +1893,25 @@ private ImExpr rewriteGenericGlobalsInExpr(ImExpr e, ImClass owningClass, Generi } private void createSpecializedGlobals(ImClass originalClass, GenericTypes generics, List typeVars) { - String key = gKey(generics); - // Collect "insert specialized init right after original init" operations per parent ImStmts // Using identity maps because IM nodes use identity semantics for parent/ownership. Map>> insertsByParent = new IdentityHashMap<>(); + List> newlyCreated = new ArrayList<>(); + // Establish the complete declaration environment before translating any initializer. An + // initializer may refer to a later static of the same class; interleaving declaration and + // initializer lowering would make correctness depend on global iteration order. for (Map.Entry entry : globalToClass.entrySet()) { ImVar originalGlobal = entry.getKey(); ImClass owningClass = entry.getValue(); - // be robust: sometimes class objects differ; name match is good enough here - if (owningClass != originalClass && !owningClass.getName().equals(originalClass.getName())) continue; + if (translator.canonical(owningClass) != translator.canonical(originalClass)) continue; - if (specializedGlobals.contains(originalGlobal, key)) continue; + if (specializedGlobals.contains(originalGlobal, generics)) continue; ImType specializedType = transformType(originalGlobal.getType(), generics, typeVars); - String specializedName = originalGlobal.getName() + "⟪" + key + "⟫"; + String specializedName = originalGlobal.getName() + "⟪" + generics.makeName() + "⟫"; ImVar specializedGlobal = JassIm.ImVar( originalGlobal.getTrace(), specializedType, @@ -1803,8 +1926,15 @@ private void createSpecializedGlobals(ImClass originalClass, GenericTypes generi translator.recordSpecialisation(specializedGlobal, originalGlobal, generics.getTypeArguments()); translator.recordGenericStaticOwner(specializedGlobal, originalClass); translator.recordGenericStaticOwner(originalGlobal, originalClass); - specializedGlobals.put(originalGlobal, key, specializedGlobal); + specializedGlobals.put(originalGlobal, generics, specializedGlobal); dbg("Created specialized global: " + specializedName + " type=" + specializedType); + newlyCreated.add(Map.entry(originalGlobal, specializedGlobal)); + } + + for (Map.Entry specialization : newlyCreated) { + ImVar originalGlobal = specialization.getKey(); + ImVar specializedGlobal = specialization.getValue(); + ImType specializedType = specializedGlobal.getType(); // If original has init(s), create corresponding specialized init(s) and schedule insertion List originalInits = prog.getGlobalInits().get(originalGlobal); @@ -2425,19 +2555,18 @@ private ImVar ensureSpecializedGlobal(ImVar originalGlobal, ImClass owningClass, concreteGenerics = normalizeToClassArity(concreteGenerics, owningClass, "ensureSpecializedGlobal:" + originalGlobal.getName()); if (concreteGenerics == null) return null; - String key = gKey(concreteGenerics); - ImVar sg = specializedGlobals.get(originalGlobal, key); + ImVar sg = specializedGlobals.get(originalGlobal, concreteGenerics); if (sg != null) return sg; // Ensure class specialization exists (this should also call createSpecializedGlobals) specializeClass(owningClass, concreteGenerics); - sg = specializedGlobals.get(originalGlobal, key); + sg = specializedGlobals.get(originalGlobal, concreteGenerics); if (sg != null) return sg; - // Absolute fallback: force-create (in case specializeClass was short-circuited) - createSpecializedGlobals(owningClass, concreteGenerics, owningClass.getTypeVariables()); - return specializedGlobals.get(originalGlobal, key); + throw new CompileError(originalGlobal, + "Generic static specialization was not created for " + originalGlobal.getName() + + " in " + owningClass.getName() + " with " + concreteGenerics + "."); } /** @@ -2508,10 +2637,7 @@ class GenericGlobalArrayAccess implements GenericUse { return r; } - /** - * NEW: Infer generic types from the enclosing function context - * For specialized functions, the name contains the type information - */ + /** Infer class type arguments from the enclosing function's structural specialization context. */ private GenericTypes inferGenericsFromFunction(Element element, ImClass owningClass) { Element current = element; while (current != null) { @@ -2533,10 +2659,10 @@ private GenericTypes inferGenericsFromFunction(Element element, ImClass owningCl ImClassType ct = (ImClassType) rt; ImClass raw = ct.getClassDef(); - boolean matches = - raw.getName().equals(owningClass.getName()) || - raw.getName().startsWith(owningClass.getName() + "⟪") || - raw.isSubclassOf(owningClass); + ImClass canonicalRaw = translator.canonical(raw); + ImClass canonicalOwner = translator.canonical(owningClass); + boolean matches = canonicalRaw == canonicalOwner + || canonicalRaw.isSubclassOf(canonicalOwner); if (matches) { if (!ct.getTypeArguments().isEmpty()) { @@ -2547,9 +2673,12 @@ private GenericTypes inferGenericsFromFunction(Element element, ImClass owningCl return normalizeToClassArity(new GenericTypes(copied), owningClass, "receiverTypeArgs:" + func.getName()); } - GenericTypes fromName = extractGenericsFromClassName(raw.getName()); - if (fromName != null && !fromName.getTypeArguments().isEmpty()) { - return normalizeToClassArity(fromName, owningClass, "className:" + raw.getName()); + ImTranslator.Specialisation classSpecialisation = translator.specialisationOf(raw); + if (classSpecialisation != null + && !classSpecialisation.typeArguments().isEmpty()) { + return normalizeToClassArity( + new GenericTypes(classSpecialisation.typeArguments()), owningClass, + "receiverSpecialisation:" + func.getName()); } } } @@ -2561,89 +2690,6 @@ private GenericTypes inferGenericsFromFunction(Element element, ImClass owningCl } return null; } - - - - /** - * NEW: Extract generic types from a specialized class name like "Box⟪integer⟫" - */ - private GenericTypes extractGenericsFromClassName(String className) { - int start = className.indexOf('⟪'); - int end = className.lastIndexOf('⟫'); - if (start < 0 || end < 0 || end <= start + 1) return null; - - String payload = className.substring(start + 1, end).trim(); - List parts = splitTopLevel(payload); // comma-split with bracket depth - List args = new ArrayList<>(parts.size()); - for (String p : parts) { - ImType t = parseTypeAtom(p.trim()); - args.add(JassIm.ImTypeArgument(t, Collections.emptyMap())); - } - return new GenericTypes(args); - } - - /** split by commas at top level, respecting both ⟪⟫ and ⦅⦆ */ - private List splitTopLevel(String s) { - List res = new ArrayList<>(); - StringBuilder cur = new StringBuilder(); - int depthAngle = 0, depthTuple = 0; - for (int i = 0; i < s.length(); i++) { - char ch = s.charAt(i); - if (ch == '⟪') depthAngle++; - else if (ch == '⟫') depthAngle--; - else if (ch == '⦅') depthTuple++; - else if (ch == '⦆') depthTuple--; - - if (ch == ',' && depthAngle == 0 && depthTuple == 0) { - res.add(cur.toString()); - cur.setLength(0); - continue; - } - cur.append(ch); - } - if (cur.length() > 0) res.add(cur.toString()); - return res; - } - - /** parse simple atoms and tuples like ⦅integer, integer⦆ (can nest) */ - private ImType parseTypeAtom(String s) { - s = s.trim(); - // tuple - if (s.startsWith("⦅") && s.endsWith("⦆")) { - String inner = s.substring(1, s.length() - 1).trim(); - List elems = splitTopLevel(inner); - List tt = new ArrayList<>(); - List names = Lists.newArrayList(); - int i = 1; - for (String e : elems) { - tt.add(parseTypeAtom(e)); - names.add("" + i++); - } - return JassIm.ImTupleType(tt, names); - } - - // common simples - switch (s) { - case "integer": - case "int": return JassIm.ImSimpleType("integer"); - case "real": return JassIm.ImSimpleType("real"); - case "boolean": - case "bool": return JassIm.ImSimpleType("boolean"); - case "string": return JassIm.ImSimpleType("string"); - } - - // class type without visible args here - for (ImClass c : prog.getClasses()) { - if (c.getName().equals(s)) { - return JassIm.ImClassType(c, JassIm.ImTypeArguments()); - } - } - // fallback: simple type with this name - return JassIm.ImSimpleType(s); - } - - - class GenericVar implements GenericUse { private final ImVar mc; diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java index f292c7f98..751a2650c 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java @@ -23,16 +23,134 @@ public class EliminateTuples { public static void eliminateTuplesProg(ImProg imProg, ImTranslator translator) { + DiscardEvaluation discardEvaluation = new DiscardEvaluation(imProg); + List removeOldVars = new ArrayList<>(); + removeOldVars.add(transformVars(imProg.getGlobals(), translator)); + for (ImClass c : imProg.getClasses()) { + removeOldVars.add(transformVars(c.getFields(), translator)); + } - Runnable removeOldGlobals = transformVars(imProg.getGlobals(), translator); - for (ImFunction f : imProg.getFunctions()) { + shareTupleReturnSlotsAcrossOverrides(imProg, translator); + List functions = allFunctions(imProg); + for (ImFunction f : functions) { transformFunctionReturnsAndParameters(f, translator); } - for (ImFunction f : imProg.getFunctions()) { - eliminateTuplesFunc(f, translator); + for (ImFunction f : functions) { + eliminateTuplesFunc(f, translator, discardEvaluation); + } + removeOldVars.forEach(Runnable::run); + assertNoTuples(imProg); + } + + private static void assertNoTuples(Element element) { + AssertProperty.NOTUPLES.check(element); + for (int i = 0; i < element.size(); i++) { + assertNoTuples(element.get(i)); + } + } + + private static List allFunctions(ImProg prog) { + LinkedHashSet result = new LinkedHashSet<>(prog.getFunctions()); + for (ImClass c : prog.getClasses()) { + result.addAll(c.getFunctions()); + } + return new ArrayList<>(result); + } + + /** Lua retains virtual methods, so all implementations in a dispatch group must write + * additional tuple return components to the same scalar return slots. */ + private static void shareTupleReturnSlotsAcrossOverrides(ImProg prog, ImTranslator translator) { + List methods = new ArrayList<>(); + Set knownMethods = Collections.newSetFromMap(new IdentityHashMap<>()); + for (ImMethod method : prog.getMethods()) { + if (knownMethods.add(method)) { + methods.add(method); + } + } + for (ImClass c : prog.getClasses()) { + for (ImMethod method : c.getMethods()) { + if (knownMethods.add(method)) { + methods.add(method); + } + } + } + for (int i = 0; i < methods.size(); i++) { + for (ImMethod subMethod : methods.get(i).getSubMethods()) { + if (knownMethods.add(subMethod)) { + methods.add(subMethod); + } + } + } + + Map parents = new IdentityHashMap<>(); + Map methodForImplementation = new IdentityHashMap<>(); + for (ImMethod method : methods) { + parents.put(method, method); + } + for (ImMethod method : methods) { + for (ImMethod subMethod : method.getSubMethods()) { + unionMethods(method, subMethod, parents); + } + ImFunction implementation = method.getImplementation(); + if (implementation != null) { + ImMethod other = methodForImplementation.putIfAbsent(implementation, method); + if (other != null) { + unionMethods(method, other, parents); + } + } + } + + Map> groupsByRoot = new IdentityHashMap<>(); + List> groups = new ArrayList<>(); + for (ImMethod method : methods) { + ImMethod root = findMethodRoot(method, parents); + List group = groupsByRoot.get(root); + if (group == null) { + group = new ArrayList<>(); + groupsByRoot.put(root, group); + groups.add(group); + } + group.add(method); + } + + for (List group : groups) { + VarsForTupleResult shared = null; + for (ImMethod method : group) { + ImFunction implementation = method.getImplementation(); + if (implementation != null + && translator.getOriginalReturnValue(implementation) instanceof ImTupleType) { + shared = translator.getTupleTempReturnVarsFor(implementation); + break; + } + } + if (shared == null) { + continue; + } + for (ImMethod method : group) { + ImFunction implementation = method.getImplementation(); + if (implementation != null + && translator.getOriginalReturnValue(implementation) instanceof ImTupleType) { + translator.setTupleTempReturnVarsFor(implementation, shared); + } + } + } + } + + private static ImMethod findMethodRoot(ImMethod method, Map parents) { + ImMethod parent = parents.get(method); + if (parent != method) { + parent = findMethodRoot(parent, parents); + parents.put(method, parent); + } + return parent; + } + + private static void unionMethods(ImMethod left, ImMethod right, Map parents) { + ImMethod leftRoot = findMethodRoot(left, parents); + ImMethod rightRoot = findMethodRoot(right, parents); + if (leftRoot != rightRoot) { + parents.put(rightRoot, leftRoot); } - removeOldGlobals.run(); - translator.assertProperties(AssertProperty.NOTUPLES); } private static void transformFunctionReturnsAndParameters(ImFunction f, ImTranslator translator) { @@ -42,18 +160,21 @@ private static void transformFunctionReturnsAndParameters(ImFunction f, ImTransl } - private static void eliminateTuplesFunc(ImFunction f, final ImTranslator translator) { + private static void eliminateTuplesFunc(ImFunction f, final ImTranslator translator, + DiscardEvaluation discardEvaluation) { transformVars(f.getLocals(), translator).run(); tryStep(f, translator, EliminateTuples::toTupleExpressions); tryStep(f, translator, EliminateTuples::normalizeTuplesInStatementExprs); - tryStep(f, translator, EliminateTuples::removeTupleSelections); + tryStep(f, translator, (stmts, tr, fn) -> + removeTupleSelections(stmts, tr, fn, discardEvaluation)); tryStep(f, translator, EliminateTuples::normalizeTuplesInStatementExprs); tryStep(f, translator, (stmts, translator1, fn) -> removeTupleExprs(0, stmts, translator1, fn)); } - private static void removeTupleSelections(ImStmts stmts, ImTranslator tr, ImFunction f) { + private static void removeTupleSelections(ImStmts stmts, ImTranslator tr, ImFunction f, + DiscardEvaluation discardEvaluation) { Replacer replacer = new Replacer(); stmts.accept(new Element.DefaultVisitor() { @Override @@ -80,10 +201,20 @@ public void visit(ImTupleSelection ts) { de.peeeq.wurstscript.ast.Element trace = te.attrTrace(); te.setParent(null); if (i != ti) { - // if not the thing we want to return, just keep it in statements for side-effects - extractSideEffect(te, stmts); + // Constructing a tuple evaluates every component. A read can be free of + // side effects and still fail (for example, a member access on null), so + // only values proven trivial to evaluate may disappear here. + ImExpr remaining = extractSideEffect(te, stmts); + retainDiscardedValue(remaining, stmts, tr, discardEvaluation); } else { // if it is the part we want to return ... - result = extractSideEffect(te, stmts); + ImExpr selected = extractSideEffect(te, stmts); + if (i < tupleExpr.getExprs().size() - 1 && !ts.isUsedAsLValue()) { + // Later tuple components still have to run, but the selected value is + // evaluated at its original position in the tuple's left-to-right order. + result = captureSelectedValue(selected, stmts, f); + } else { + result = selected; + } } } assert result != null; @@ -98,6 +229,93 @@ public void visit(ImTupleSelection ts) { }); } + private static ImExpr captureSelectedValue(ImExpr selected, ImStmts stmts, ImFunction f) { + if (selected instanceof ImTupleExpr tuple) { + ImExprs captured = JassIm.ImExprs(); + for (ImExpr component : tuple.getExprs()) { + component.setParent(null); + captured.add(captureSelectedValue(extractSideEffect(component, stmts), stmts, f)); + } + return JassIm.ImTupleExpr(captured); + } + ImVar temp = JassIm.ImVar(selected.attrTrace(), selected.attrTyp(), "tupleSelection", false); + f.getLocals().add(temp); + selected.setParent(null); + stmts.add(JassIm.ImSet(selected.attrTrace(), JassIm.ImVarAccess(temp), selected)); + return JassIm.ImVarAccess(temp); + } + + private static void retainDiscardedValue(ImExpr value, ImStmts stmts, ImTranslator tr, + DiscardEvaluation discardEvaluation) { + if (value instanceof ImTupleExpr tuple) { + for (ImExpr component : tuple.getExprs()) { + component.setParent(null); + retainDiscardedValue(extractSideEffect(component, stmts), stmts, tr, + discardEvaluation); + } + return; + } + if (isTriviallyDiscardable(value)) { + return; + } + if (SideEffectAnalyzer.quickcheckHasSideeffects(value)) { + value.setParent(null); + stmts.add(value); + } else if (tr.isLuaTarget()) { + value.setParent(null); + stmts.add(discardEvaluation.call(value)); + } + } + + private static boolean isTriviallyDiscardable(ImExpr value) { + return value instanceof ImBoolVal + || value instanceof ImIntVal + || value instanceof ImRealVal + || value instanceof ImStringVal + || value instanceof ImNull + || value instanceof ImVarAccess + || value instanceof ImFuncRef; + } + + /** + * Lua must evaluate unused tuple components which can still trap. Passing such a value to a + * tiny non-native sink makes argument evaluation explicit and keeps later optimizers from + * deleting it as an unread local assignment. One sink is shared by every scalar IM type. + */ + private static final class DiscardEvaluation { + private final ImProg prog; + private final List functionsByType = new ArrayList<>(); + + private record DiscardFunction(ImType type, ImFunction function) { + } + + private DiscardEvaluation(ImProg prog) { + this.prog = prog; + } + + private ImFunctionCall call(ImExpr value) { + ImType type = value.attrTyp(); + ImFunction sink = functionsByType.stream() + .filter(entry -> entry.type().equalsType(type)) + .map(DiscardFunction::function) + .findFirst() + .orElseGet(() -> createSink(value, type)); + return JassIm.ImFunctionCall(value.attrTrace(), sink, JassIm.ImTypeArguments(), + JassIm.ImExprs(value), false, CallType.NORMAL); + } + + private ImFunction createSink(ImExpr value, ImType type) { + ImVar parameter = JassIm.ImVar(value.attrTrace(), type.copy(), "value", false); + ImFunction sink = JassIm.ImFunction(value.attrTrace(), + "__wurst_tuple_discard_" + functionsByType.size(), JassIm.ImTypeVars(), + JassIm.ImVars(parameter), JassIm.ImVoid(), JassIm.ImVars(), JassIm.ImStmts(), + Collections.emptyList()); + functionsByType.add(new DiscardFunction(type.copy(), sink)); + prog.getFunctions().add(sink); + return sink; + } + } + interface Step { void apply(ImStmts e, ImTranslator t, ImFunction f); } @@ -199,13 +417,8 @@ public void visit(ImVarArrayAccess va) { ImExprs indexes = va.getIndexes(); ImExprs indexExprs = JassIm.ImExprs(); ImStmts stmts = JassIm.ImStmts(); - boolean sideEffects = false; - for (ImExpr index : indexes) { - if (SideEffectAnalyzer.quickcheckHasSideeffects(index)) { - sideEffects = true; - break; - } - } + boolean sideEffects = indexes.stream() + .anyMatch(SideEffectAnalyzer::quickcheckHasSideeffects); for (ImExpr ie : indexes) { if (sideEffects) { // use temp variables if there are side effects @@ -237,6 +450,25 @@ public void visit(ImVarArrayAccess va) { } } + @Override + public void visit(ImMemberAccess ma) { + super.visit(ma); + if (ma.attrTyp() instanceof ImTupleType) { + ImStmts stmts = JassIm.ImStmts(); + ImExpr receiver = captureOnceIfNeeded(ma.getReceiver(), "tupleReceiver", stmts, f); + ImExprs indexes = captureIndexesOnceIfNeeded(ma.getIndexes(), stmts, f); + VarsForTupleResult vars = translator.getVarsForTuple(ma.getVar()); + ImExpr replacement = vars.map( + parts -> JassIm.ImTupleExpr(parts.collect(Collectors.toCollection(JassIm::ImExprs))), + var -> JassIm.ImMemberAccess(ma.getTrace(), receiver.copy(), ma.getTypeArguments().copy(), + var, indexes.copy())); + if (!stmts.isEmpty()) { + replacement = JassIm.ImStatementExpr(stmts, replacement); + } + replacer.replace(ma, replacement); + } + } + @Override public void visit(ImFunctionCall fc) { @@ -261,9 +493,53 @@ public void visit(ImFunctionCall fc) { } } + @Override + public void visit(ImMethodCall mc) { + super.visit(mc); + ImFunction implementation = mc.getMethod().getImplementation(); + if (implementation != null && translator.getOriginalReturnValue(implementation) instanceof ImTupleType) { + Element parent = mc.getParent(); + mc.setParent(null); + VarsForTupleResult returnVars = translator.getTupleTempReturnVarsFor(implementation); + ImVar firstVar = returnVars.allValuesStream().findFirst().get(); + ImExpr newCall = returnVars.map( + parts -> JassIm.ImTupleExpr(parts.collect(Collectors.toCollection(JassIm::ImExprs))), + var -> var == firstVar ? mc.copy() : JassIm.ImVarAccess(var)); + replacer.replaceInParent(parent, mc, newCall); + } + } + }); } + private static ImExpr captureOnceIfNeeded(ImExpr expr, String name, ImStmts stmts, ImFunction f) { + if (!SideEffectAnalyzer.quickcheckHasSideeffects(expr)) { + return expr; + } + ImVar temp = JassIm.ImVar(expr.attrTrace(), expr.attrTyp(), name, false); + f.getLocals().add(temp); + expr.setParent(null); + stmts.add(JassIm.ImSet(expr.attrTrace(), JassIm.ImVarAccess(temp), expr)); + return JassIm.ImVarAccess(temp); + } + + private static ImExprs captureIndexesOnceIfNeeded(ImExprs original, ImStmts stmts, ImFunction f) { + boolean capture = original.stream().anyMatch(SideEffectAnalyzer::quickcheckHasSideeffects); + ImExprs result = JassIm.ImExprs(); + for (ImExpr index : original) { + if (capture) { + ImVar temp = JassIm.ImVar(index.attrTrace(), index.attrTyp(), "tupleIndex", false); + f.getLocals().add(temp); + index.setParent(null); + stmts.add(JassIm.ImSet(index.attrTrace(), JassIm.ImVarAccess(temp), index)); + result.add(JassIm.ImVarAccess(temp)); + } else { + result.add(index.copy()); + } + } + return result; + } + /** * Normalize Tuples in statement-expressions (move to first tuple param) @@ -330,12 +606,17 @@ private static void removeTupleExprs(int posHint, Element elem, ImTranslator tra newElem = inReturn((ImReturn) elem, tupleExpr, translator, f); } else if (elem instanceof ImSet) { ImSet imSet = (ImSet) elem; - newElem = inSet(imSet, f); + newElem = inSet(imSet, translator, f); } else if (elem instanceof ImExprs) { ImExprs exprs = (ImExprs) elem; if (exprs.getParent() instanceof ImOperatorCall) { ImOperatorCall opCall = (ImOperatorCall) exprs.getParent(); - handleTupleInOpCall(replacer, opCall); + handleTupleInOpCall(replacer, opCall, f); + return; + } else if (exprs.getParent() instanceof ImFunctionCall + || exprs.getParent() instanceof ImMethodCall) { + ImExpr call = (ImExpr) exprs.getParent(); + replacer.replace(call, stageTupleCallArguments(call, exprs, f)); return; } else { // in function arguments, other tuples @@ -368,7 +649,42 @@ private static void removeTupleExprs(int posHint, Element elem, ImTranslator tra } - private static void handleTupleInOpCall(Replacer replacer, ImOperatorCall opCall) { + private static ImStatementExpr stageTupleCallArguments(ImExpr call, ImExprs arguments, + ImFunction f) { + ImStmts evaluation = JassIm.ImStmts(); + boolean forceOrder = arguments.stream().anyMatch(EliminateTuples::needsOrderedCapture); + if (call instanceof ImMethodCall methodCall) { + forceOrder |= needsOrderedCapture(methodCall.getReceiver()); + } + + // A dynamic receiver is evaluated before the arguments in the source program. Keep it in + // the same ordered prelude as the flattened tuple components. + if (call instanceof ImMethodCall methodCall) { + ImExpr receiver = methodCall.getReceiver(); + receiver.setParent(null); + OrderedBundle receiverBundle = lowerBundle(receiver, f, forceOrder, + "tuple_argument_receiver"); + receiverBundle.appendPreludeTo(evaluation); + if (receiverBundle.values.size() != 1) { + throw new CompileError(call.attrTrace(), "A method receiver cannot be a tuple."); + } + methodCall.setReceiver(receiverBundle.values.getFirst()); + } + + List originalArguments = arguments.removeAll(); + for (ImExpr argument : originalArguments) { + argument.setParent(null); + OrderedBundle argumentBundle = lowerBundle(argument, f, forceOrder, "tuple_argument"); + argumentBundle.appendPreludeTo(evaluation); + arguments.addAll(argumentBundle.values); + } + + // Keep the original node in place until Replacer has found its parent. The detached copy is + // the scalar-only call evaluated after the complete left-to-right argument prelude. + return JassIm.ImStatementExpr(evaluation, (ImExpr) call.copy()); + } + + private static void handleTupleInOpCall(Replacer replacer, ImOperatorCall opCall, ImFunction f) { if (opCall.getParent() == null) { throw new RuntimeException("opCall not used: " + opCall); } @@ -376,12 +692,18 @@ private static void handleTupleInOpCall(Replacer replacer, ImOperatorCall opCall ImTupleExpr right = (ImTupleExpr) opCall.getArguments().get(1); WurstOperator op = opCall.getOp(); + ImStmts evaluation = JassIm.ImStmts(); + boolean forceOrder = needsOrderedCapture(left) || needsOrderedCapture(right); + List leftComponents = captureTupleComponents(left, evaluation, f, forceOrder); + List rightComponents = captureTupleComponents(right, evaluation, f, forceOrder); + if (leftComponents.size() != rightComponents.size()) { + throw new CompileError(opCall.attrTrace(), "Cannot compare tuples with different arity."); + } + List componentComparisons = new ArrayList<>(); - for (int i = 0; i < left.getExprs().size(); i++) { - ImExpr l = left.getExprs().get(i); - ImExpr r = right.getExprs().get(i); - l.setParent(null); - r.setParent(null); + for (int i = 0; i < leftComponents.size(); i++) { + ImExpr l = leftComponents.get(i); + ImExpr r = rightComponents.get(i); componentComparisons.add(JassIm.ImOperatorCall(op, JassIm.ImExprs(l, r))); } @@ -418,12 +740,36 @@ private static void handleTupleInOpCall(Replacer replacer, ImOperatorCall opCall newExpr = (seen ? Optional.of(acc) : Optional.empty()) .get(); } - replacer.replace(opCall, newExpr); + replacer.replace(opCall, JassIm.ImStatementExpr(evaluation, newExpr)); + } + + private static List captureTupleComponents(ImTupleExpr tuple, ImStmts evaluation, + ImFunction f, boolean forceOrder) { + OrderedBundle bundle = lowerBundle(tuple, f, forceOrder, "tuple_compare"); + bundle.appendPreludeTo(evaluation); + return bundle.values; } - private static ImStatementExpr inSet(ImSet imSet, ImFunction f) { + private static ImStatementExpr inSet(ImSet imSet, ImTranslator translator, ImFunction f) { + registerConcreteTupleStorage(imSet.getLeft(), imSet.getRight(), translator); + registerConcreteTupleStorage(imSet.getRight(), imSet.getLeft(), translator); + if (!(imSet.getLeft() instanceof ImTupleExpr) && imSet.getRight() instanceof ImTupleExpr) { + ImTupleExpr expanded = expandTupleStorageAccess(imSet.getLeft(), translator); + if (expanded != null) { + imSet.setLeft(expanded); + } + } + if (!(imSet.getRight() instanceof ImTupleExpr) && imSet.getLeft() instanceof ImTupleExpr + && imSet.getRight() instanceof ImLExpr) { + ImTupleExpr expanded = expandTupleStorageAccess((ImLExpr) imSet.getRight(), translator); + if (expanded != null) { + imSet.setRight(expanded); + } + } if (!(imSet.getLeft() instanceof ImTupleExpr && imSet.getRight() instanceof ImTupleExpr)) { - throw new RuntimeException("invalid set statement:\n" + imSet); + throw new RuntimeException("invalid set statement:\n" + imSet + + "\nleft type=" + imSet.getLeft().attrTyp() + + " right type=" + imSet.getRight().attrTyp()); } ImTupleExpr left = (ImTupleExpr) imSet.getLeft(); ImTupleExpr right = (ImTupleExpr) imSet.getRight(); @@ -433,14 +779,14 @@ private static ImStatementExpr inSet(ImSet imSet, ImFunction f) { // 1) Flatten LHS into L-values (recursively), hoisting side-effects List lhsLeaves = new ArrayList<>(); for (ImExpr e : left.getExprs()) { - flattenLhsTuple(e, lhsLeaves, stmts); + flattenLhsTuple(e, lhsLeaves, stmts, f); } - // 2) Flatten RHS into expressions (recursively), expanding null to defaults, hoisting side-effects - List rhsLeaves = new ArrayList<>(); - for (ImExpr e : right.getExprs()) { - flattenRhsTuple(e, rhsLeaves, stmts); - } + // 2) Capture RHS leaves at their original positions. Assignment always forces capture for + // non-immutable leaves: this preserves swaps/aliasing even when the RHS itself is pure. + OrderedBundle rhs = lowerBundle(right, f, true, "tuple_assignment"); + rhs.appendPreludeTo(stmts); + List rhsLeaves = rhs.values; // 3) Pad / normalize RHS arity to match LHS arity (needed for nested tuples + null) for (int i = rhsLeaves.size(); i < lhsLeaves.size(); i++) { @@ -455,103 +801,173 @@ private static ImStatementExpr inSet(ImSet imSet, ImFunction f) { + "\nLHS=" + left + "\nRHS=" + right); } - boolean allLiteral = true; - for (ImExpr r : rhsLeaves) { - if (!isSimpleLiteral(r)) { - allLiteral = false; - break; - } - } - - if (allLiteral) { - for (int i = 0; i < lhsLeaves.size(); i++) { - ImLExpr l = lhsLeaves.get(i); - ImType targetT = l.attrTyp(); - ImExpr r = rhsLeaves.get(i); - if (r instanceof ImNull) { - r = ImHelper.defaultValueForComplexType(targetT); - } - l.setParent(null); - r.setParent(null); - stmts.add(JassIm.ImSet(imSet.getTrace(), l, r)); - } - return ImHelper.statementExprVoid(stmts); - } - - // 4) Evaluate RHS leaves first into temps (preserve side-effect order & alias safety) - List temps = new ArrayList<>(rhsLeaves.size()); - for (int i = 0; i < rhsLeaves.size(); i++) { + // 4) Publish only after every RHS component has been captured. + for (int i = 0; i < lhsLeaves.size(); i++) { ImLExpr l = lhsLeaves.get(i); - ImType targetT = l.attrTyp(); - ImExpr r = rhsLeaves.get(i); - - // if a scalar null slipped through, replace with default of target type + ImExpr r = rhsLeaves.get(i); if (r instanceof ImNull) { - r = ImHelper.defaultValueForComplexType(targetT); + r = ImHelper.defaultValueForComplexType(l.attrTyp()); } - - ImVar t = JassIm.ImVar(r.attrTrace(), targetT, "tuple_temp", false); - f.getLocals().add(t); - - r.setParent(null); - stmts.add(JassIm.ImSet(r.attrTrace(), JassIm.ImVarAccess(t), r)); - temps.add(t); - } - - // 5) Now assign temps into LHS leaves - for (int i = 0; i < lhsLeaves.size(); i++) { - ImLExpr l = lhsLeaves.get(i); l.setParent(null); - stmts.add(JassIm.ImSet(imSet.getTrace(), l, JassIm.ImVarAccess(temps.get(i)))); + r.setParent(null); + stmts.add(JassIm.ImSet(imSet.getTrace(), l, r)); } return ImHelper.statementExprVoid(stmts); } - private static boolean isSimpleLiteral(ImExpr expr) { - return expr instanceof ImBoolVal - || expr instanceof ImIntVal - || expr instanceof ImRealVal - || expr instanceof ImStringVal - || expr instanceof ImNull; + private static void registerConcreteTupleStorage(ImExpr storage, ImExpr value, + ImTranslator translator) { + if (!(value.attrTyp() instanceof ImTupleType tupleType)) { + return; + } + ImVar var; + ImType concreteType; + if (storage instanceof ImVarAccess access) { + var = access.getVar(); + concreteType = tupleType.copy(); + } else if (storage instanceof ImVarArrayAccess access) { + var = access.getVar(); + concreteType = JassIm.ImArrayType(tupleType.copy()); + } else if (storage instanceof ImMemberAccess access) { + var = access.getVar(); + if (var.getType() instanceof ImArrayType) { + concreteType = JassIm.ImArrayType(tupleType.copy()); + } else { + concreteType = tupleType.copy(); + } + } else { + return; + } + translator.getVarsForTuple(var, concreteType); + } + + private static @org.eclipse.jdt.annotation.Nullable ImTupleExpr expandTupleStorageAccess( + ImLExpr left, ImTranslator translator) { + if (left instanceof ImVarAccess access) { + ImExpr expanded = translator.getVarsForTuple(access.getVar()).map( + parts -> JassIm.ImTupleExpr(parts.collect(Collectors.toCollection(JassIm::ImExprs))), + JassIm::ImVarAccess); + return expanded instanceof ImTupleExpr ? (ImTupleExpr) expanded : null; + } + if (left instanceof ImVarArrayAccess access) { + ImExpr expanded = translator.getVarsForTuple(access.getVar()).map( + parts -> JassIm.ImTupleExpr(parts.collect(Collectors.toCollection(JassIm::ImExprs))), + var -> JassIm.ImVarArrayAccess(access.getTrace(), var, access.getIndexes().copy())); + return expanded instanceof ImTupleExpr ? (ImTupleExpr) expanded : null; + } + if (left instanceof ImMemberAccess access) { + ImExpr expanded = translator.getVarsForTuple(access.getVar()).map( + parts -> JassIm.ImTupleExpr(parts.collect(Collectors.toCollection(JassIm::ImExprs))), + var -> JassIm.ImMemberAccess(access.getTrace(), access.getReceiver().copy(), + access.getTypeArguments().copy(), var, access.getIndexes().copy())); + return expanded instanceof ImTupleExpr ? (ImTupleExpr) expanded : null; + } + return null; } /** Flatten LHS recursively into addressable leaves (ImLExpr), hoisting side-effects */ - private static void flattenLhsTuple(ImExpr e, List out, ImStmts sideStmts) { + private static void flattenLhsTuple(ImExpr e, List out, ImStmts sideStmts, ImFunction f) { ImExpr x = extractSideEffect(e, sideStmts); if (x instanceof ImTupleExpr) { for (ImExpr sub : ((ImTupleExpr) x).getExprs()) { - flattenLhsTuple(sub, out, sideStmts); + flattenLhsTuple(sub, out, sideStmts, f); } } else { - out.add((ImLExpr) x); + out.add(captureLvalueAddress((ImLExpr) x, sideStmts, f)); } } - /** Flatten RHS recursively into leaves, expanding null to tuple of defaults, hoisting side-effects */ - private static void flattenRhsTuple(ImExpr e, List out, ImStmts sideStmts) { - ImExpr x = extractSideEffect(e, sideStmts); + /** Capture the address-bearing parts of an lvalue before evaluating the assignment RHS. */ + private static ImLExpr captureLvalueAddress(ImLExpr lvalue, ImStmts stmts, ImFunction f) { + if (lvalue instanceof ImMemberAccess access) { + ImExpr receiver = access.getReceiver(); + receiver.setParent(null); + access.setReceiver(captureValue(receiver, "tuple_lvalue_receiver", stmts, f)); + captureLvalueIndexes(access.getIndexes(), stmts, f); + } else if (lvalue instanceof ImVarArrayAccess access) { + captureLvalueIndexes(access.getIndexes(), stmts, f); + } + return lvalue; + } - // Expand typed nulls for tuple types so arities match - if (x instanceof ImNull) { - ImType t = ((ImNull) x).getType(); - if (t instanceof ImTupleType) { - ImExpr defaults = ImHelper.defaultValueForComplexType(t); // -> ImTupleExpr of defaults - flattenRhsTuple(defaults, out, sideStmts); - return; + private static void captureLvalueIndexes(ImExprs indexes, ImStmts stmts, ImFunction f) { + for (int i = 0; i < indexes.size(); i++) { + ImExpr index = indexes.get(i); + index.setParent(null); + indexes.set(i, captureValue(index, "tuple_lvalue_index", stmts, f)); + } + } + + private static ImExpr captureValue(ImExpr value, String name, ImStmts stmts, ImFunction f) { + ImVar temp = JassIm.ImVar(value.attrTrace(), value.attrTyp(), name, false); + f.getLocals().add(temp); + stmts.add(JassIm.ImSet(value.attrTrace(), JassIm.ImVarAccess(temp), value)); + return JassIm.ImVarAccess(temp); + } + + /** + * A tuple value after lowering: statements which must run in source order, followed by its + * identity-free scalar components. When ordering matters, each mutable/effectful component is + * captured as soon as it is reached; a later component can therefore neither change an earlier + * read nor overwrite a shared tuple-return slot. + */ + private static final class OrderedBundle { + private final ImStmts prelude = JassIm.ImStmts(); + private final List values = new ArrayList<>(); + + private void appendPreludeTo(ImStmts target) { + for (ImStmt statement : prelude.removeAll()) { + statement.setParent(null); + target.add(statement); } } + } - if (x instanceof ImTupleExpr) { - for (ImExpr sub : ((ImTupleExpr) x).getExprs()) { - flattenRhsTuple(sub, out, sideStmts); + private static OrderedBundle lowerBundle(ImExpr expression, ImFunction f, boolean forceOrder, + String temporaryName) { + OrderedBundle result = new OrderedBundle(); + lowerBundleInto(expression, f, forceOrder || needsOrderedCapture(expression), temporaryName, + result); + return result; + } + + private static void lowerBundleInto(ImExpr expression, ImFunction f, boolean capture, + String temporaryName, OrderedBundle result) { + ImExpr value = extractSideEffect(expression, result.prelude); + if (value instanceof ImNull nullValue && nullValue.getType() instanceof ImTupleType) { + lowerBundleInto(ImHelper.defaultValueForComplexType(nullValue.getType()), f, capture, + temporaryName, result); + return; + } + if (value instanceof ImTupleExpr tuple) { + for (ImExpr component : new ArrayList<>(tuple.getExprs())) { + lowerBundleInto(component, f, capture, temporaryName, result); } + return; + } + + value.setParent(null); + if (capture && !isImmutableValue(value)) { + result.values.add(captureValue(value, temporaryName, result.prelude, f)); } else { - out.add(x); + result.values.add(value); } } + private static boolean needsOrderedCapture(ImExpr expression) { + return SideEffectAnalyzer.quickcheckHasSideeffects(expression); + } + private static boolean isImmutableValue(ImExpr expression) { + return expression instanceof ImBoolVal + || expression instanceof ImIntVal + || expression instanceof ImRealVal + || expression instanceof ImStringVal + || expression instanceof ImNull + || expression instanceof ImFuncRef + || expression instanceof ImTypeIdOfClass; + } private static ImStatementExpr inReturn(ImReturn parent, ImTupleExpr tupleExpr, ImTranslator translator, ImFunction f) { @@ -561,64 +977,36 @@ private static ImStatementExpr inReturn(ImReturn parent, ImTupleExpr tupleExpr, ImStmts stmts = JassIm.ImStmts(); - // 1) Flatten the RHS tuple expression (preserving side effects) - List flatExprs = new ArrayList<>(); - flattenTupleExpr(tupleExpr, stmts, flatExprs); + // 1) Lower and, where necessary, capture each component at its original evaluation point. + OrderedBundle result = lowerBundle(tupleExpr, f, false, "tuple_return"); + result.appendPreludeTo(stmts); // Sanity: - if (flatExprs.size() != returnVars.size()) { + if (result.values.size() != returnVars.size()) { throw new CompileError(parent.getTrace(), - "Cannot return tuple with " + flatExprs.size() + " element(s) from function expecting " + returnVars.size() + " element(s)"); + "Cannot return tuple with " + result.values.size() + + " element(s) from function expecting " + returnVars.size() + " element(s)"); } - // 2) Assign per component, converting nulls to proper defaults of LHS type + // 2) Publish only after the complete ordered bundle has been evaluated. Effectful bundles + // have already captured mutable reads and shared return slots in their prelude. for (int i = 0; i < returnVars.size(); i++) { ImVar rv = returnVars.get(i); - ImExpr rhs = flatExprs.get(i); + ImExpr rhs = result.values.get(i); rhs.setParent(null); if (rhs instanceof ImNull) { - // Use the *component target type* to build the correct default (0 for ints, - // (0,0) for tuple components if those ever occur, etc) - ImExpr defaultRhs = ImHelper.defaultValueForComplexType(rv.getType()); - stmts.add(JassIm.ImSet(parent.getTrace(), JassIm.ImVarAccess(rv), defaultRhs)); - } else { - stmts.add(JassIm.ImSet(parent.getTrace(), JassIm.ImVarAccess(rv), rhs)); + rhs = ImHelper.defaultValueForComplexType(rv.getType()); } + stmts.add(JassIm.ImSet(parent.getTrace(), JassIm.ImVarAccess(returnVars.get(i)), + rhs)); } - // 3) Return the first component temp + // 3) Return the first component slot stmts.add(JassIm.ImReturn(parent.getTrace(), JassIm.ImVarAccess(returnVars.get(0)))); return ImHelper.statementExprVoid(stmts); } - private static void flattenTupleExpr(ImExpr e, ImStmts intoStmts, List out) { - // Hoist side-effects out of the way first: - ImExpr noSE = extractSideEffect(e, intoStmts); - - // NEW: expand null into a tuple of defaults so arity matches - if (noSE instanceof ImNull) { - ImType t = ((ImNull) noSE).getType(); // already the typed null - if (t instanceof ImTupleType) { - ImExpr defaultTuple = ImHelper.defaultValueForComplexType(t); // -> ImTupleExpr of defaults (recursively) - flattenTupleExpr(defaultTuple, intoStmts, out); - return; - } - } - - if (noSE instanceof ImTupleExpr) { - ImTupleExpr te = (ImTupleExpr) noSE; - for (ImExpr sub : te.getExprs()) { - flattenTupleExpr(sub, intoStmts, out); - } - } else { - out.add(noSE); - } - } - - - - private static Element inTupleSelection(ImTupleSelection ts, ImTupleExpr tupleExpr, ImFunction f) { assert ts.getTupleExpr() == tupleExpr; @@ -685,13 +1073,4 @@ private static ImExpr extractSideEffect(ImExpr e, List into) { } return e; } - - - private static ImExprs accessVars(List tempIndexes) { - return tempIndexes.stream() - .map(JassIm::ImVarAccess) - .collect(Collectors.toCollection(JassIm::ImExprs)); - } - - } 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 248d20691..ce7f63552 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 @@ -1925,6 +1925,28 @@ public VarsForTupleResult getVarsForTuple(ImVar v) { return result; } + VarsForTupleResult getVarsForTuple(ImVar v, ImType concreteStorageType) { + if (!TypesHelper.typeContainsTuples(v.getType()) + && TypesHelper.typeContainsTuples(concreteStorageType)) { + VarsForTupleResult result = varsForTupleVar.get(v); + if (result != null) { + return result; + } + result = createVarsForType(v.getName(), concreteStorageType, Function.identity(), v.getTrace()); + varsForTupleVar.put(v, result); + if (v.getParent() instanceof ImVars owner) { + int position = owner.indexOf(v) + 1; + for (ImVar scalar : result.allValues()) { + if (!owner.contains(scalar)) { + owner.add(position++, scalar); + } + } + } + return result; + } + return getVarsForTuple(v); + } + /** * Creates variables for the given type, eliminating tuple types @@ -2046,6 +2068,10 @@ public VarsForTupleResult getTupleTempReturnVarsFor(ImFunction f) { return result; } + void setTupleTempReturnVarsFor(ImFunction f, VarsForTupleResult vars) { + tempReturnVars.put(f, vars); + } + private final Map originalReturnValues = Maps.newLinkedHashMap(); 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 29c065da9..8df6fc5fa 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 @@ -1062,10 +1062,16 @@ private void initClassTables(ImClass c) { )); // set typeid metadata: + ImClass typeIdClass = c; + ImTranslator.Specialisation specialization = imTr.specialisationOf(c); + if (specialization != null && specialization.original() instanceof ImClass original) { + // Targeted Lua specialization preserves the nominal identity of the erased class. + typeIdClass = original; + } deferMainInit(LuaAst.LuaAssignment(LuaAst.LuaExprFieldAccess( LuaAst.LuaExprVarAccess(classVar), ExprTranslation.TYPE_ID), - LuaAst.LuaExprIntVal("" + prog.attrTypeId().get(c)) + LuaAst.LuaExprIntVal("" + prog.attrTypeId().get(typeIdClass)) )); @@ -1480,6 +1486,12 @@ private void collectSuperClasses(LuaTableFields superClasses, ImClass c, Set !used.getClasses().contains(c)); prog.getGlobals().removeIf(g -> !used.getVars().contains(g) && !TRVEHelper.protectedVariables.contains(g.getName())); prog.getFunctions().removeIf(f -> !used.getFunctions().contains(f)); + prog.getMethods().removeIf(m -> !used.getMethods().contains(m)); + for (ImMethod m : prog.getMethods()) { + m.getSubMethods().removeIf(sm -> !used.getMethods().contains(sm)); + } // A field of a specialised class is a copy which nothing refers to, an access made before // specialisation still naming the original's variable. It is live exactly when the field it // was copied from is; dropping it leaves an instance of the specialised class allocated with 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 5bf33791a..2f6627095 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 @@ -12,7 +12,10 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; +import java.util.Random; import static org.testng.AssertJUnit.assertEquals; import static org.testng.AssertJUnit.assertFalse; @@ -140,6 +143,531 @@ public void legacyGenericHandleCastsUseObjectIndexMap() throws IOException { .matcher(compiled).find()); } + @Test + public void tuplesAreScalarizedWithoutLuaAllocations() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple vec2(real x, real y)", + "tuple segment(vec2 start, vec2 finish)", + "vec2 array points", + "abstract class Producer", + " vec2 offset", + " abstract function produce(real x) returns vec2", + "class Concrete extends Producer", + " override function produce(real x) returns vec2", + " return vec2(x + offset.x, x + offset.y)", + "function shifted(segment s, vec2 delta) returns segment", + " return segment(vec2(s.start.x + delta.x, s.start.y + delta.y),", + " vec2(s.finish.x + delta.x, s.finish.y + delta.y))", + "init", + " Producer producer = new Concrete()", + " producer.offset = vec2(3., 4.)", + " points[2] = producer.produce(5.)", + " let result = shifted(segment(points[2], vec2(10., 20.)), vec2(1., 2.))", + " if points[2] == vec2(8., 9.) and result.start == vec2(9., 11.)", + " and result.finish == vec2(11., 22.)", + " testSuccess()" + ); + + String compiled = compiledLua("tuplesAreScalarizedWithoutLuaAllocations"); + assertFalse("tuple assignment must not allocate through a copy helper", compiled.contains("tupleCopy")); + assertFalse("tuple comparison must be lowered to scalar comparisons", compiled.contains("tupleEquals")); + assertFalse("tuple arrays must be split into scalar arrays", compiled.contains("__wurst_arrIndex(")); + } + + @Test + public void optimizedTupleCommonPathIsOnlyScalarCode() { + String compiled = compileOptimizedLua( + "optimizedTupleCommonPathIsOnlyScalarCode", + "package Test", + "tuple vec2(real x, real y)", + "native consume(real x, real y)", + "@noinline function add(vec2 left, vec2 right) returns vec2", + " return vec2(left.x + right.x, left.y + right.y)", + "init", + " let result = add(vec2(1., 2.), vec2(3., 4.))", + " consume(result.x, result.y)" + ); + + java.util.regex.Matcher add = java.util.regex.Pattern + .compile("function add\\(([^)]*)\\)\\s*\\n(.*?)\\nend", java.util.regex.Pattern.DOTALL) + .matcher(compiled); + assertTrue("optimized tuple function must remain for shape inspection", add.find()); + assertEquals("both vec2 parameters must unfold into four scalar parameters", + 4, add.group(1).split(",").length); + assertFalse("the optimized tuple function body must not allocate a Lua table", + add.group(2).contains("{")); + assertFalse(compiled.contains("tupleCopy")); + assertFalse(compiled.contains("tupleEquals")); + } + + @Test + public void randomizedTupleValueSemanticsStayScalar() throws IOException { + Random random = new Random(0x5CA1A2L); + List source = new ArrayList<>(); + source.add("package Test"); + source.add("native testSuccess()"); + source.add("tuple pair(int x, int y)"); + source.add("init"); + source.add(" int checksum = 0"); + int expected = 0; + for (int i = 0; i < 64; i++) { + int ax = random.nextInt(101) - 50; + int ay = random.nextInt(101) - 50; + int bx = random.nextInt(101) - 50; + int by = random.nextInt(101) - 50; + int resultX = ay + bx; + int resultY = ax - by; + source.add(" pair a" + i + " = pair(" + ax + ", " + ay + ")"); + source.add(" let b" + i + " = pair(" + bx + ", " + by + ")"); + source.add(" a" + i + " = pair(a" + i + ".y + b" + i + ".x, a" + i + ".x - b" + i + ".y)"); + source.add(" checksum += a" + i + ".x * " + (i + 1) + " + a" + i + ".y"); + expected += resultX * (i + 1) + resultY; + } + source.add(" if checksum == " + expected); + source.add(" testSuccess()"); + + test().testLua(true).executeProg().lines(source.toArray(new String[0])); + String compiled = compiledLua("randomizedTupleValueSemanticsStayScalar"); + assertFalse(compiled.contains("tupleCopy")); + assertFalse(compiled.contains("tupleEquals")); + } + + @Test + public void randomizedTupleEvaluationMatchesInterpreterAndLua() throws IOException { + Random random = new Random(0x0D1FF3A7L); + List source = new ArrayList<>(); + source.add("package Test"); + source.add("native testSuccess()"); + source.add("tuple pair(int x, int y)"); + source.add("tuple nested(pair left, pair right)"); + source.add("int trace"); + source.add("int calls"); + source.add("int mutable"); + source.add("class Holder"); + source.add(" pair value"); + source.add("Holder current"); + source.add("Holder replacement"); + source.add("int currentIndex"); + source.add("pair array values"); + source.add("function mark(int value) returns int"); + source.add(" trace = trace * 37 + value"); + source.add(" return value"); + source.add("@noinline function produce(int seed) returns pair"); + source.add(" calls++"); + source.add(" return pair(mark(seed), mark(seed + 1))"); + source.add("@noinline function recursive(int seed) returns pair"); + source.add(" if seed == 0"); + source.add(" return pair(mark(7), recursive(1).x)"); + source.add(" return pair(mark(seed), mark(seed + 10))"); + source.add("function retarget(int x, int y) returns pair"); + source.add(" current = replacement"); + source.add(" currentIndex = 2"); + source.add(" return pair(mark(x), mark(y))"); + source.add("function mutatingPair(int replacementValue, int x, int y) returns pair"); + source.add(" mutable = replacementValue"); + source.add(" return pair(mark(x), mark(y))"); + source.add("function scorePairs(pair first, pair second) returns int"); + source.add(" return first.x * 41 + first.y * 43 + second.x * 47 + second.y * 53"); + source.add("init"); + source.add(" int checksum = 0"); + + int expected = 0; + for (int i = 0; i < 96; i++) { + int a = random.nextInt(9) + 1; + int b = random.nextInt(9) + 1; + int c = random.nextInt(9) + 1; + int d = random.nextInt(9) + 1; + switch (random.nextInt(8)) { + case 0 -> { + boolean selectFirst = random.nextBoolean(); + source.add(" trace = 0"); + source.add(" let selected" + i + " = pair(mark(" + a + "), mark(" + b + "))." + + (selectFirst ? "x" : "y")); + source.add(" checksum += trace + selected" + i + " * 13"); + expected += a * 37 + b + (selectFirst ? a : b) * 13; + } + case 1 -> { + int selection = random.nextInt(4); + String[] paths = {"left.x", "left.y", "right.x", "right.y"}; + int[] values = {a, b, c, d}; + source.add(" trace = 0"); + source.add(" let selected" + i + " = nested(pair(mark(" + a + "), mark(" + b + + ")), pair(mark(" + c + "), mark(" + d + ")))." + paths[selection]); + source.add(" checksum += trace + selected" + i + " * 17"); + expected += (((a * 37 + b) * 37 + c) * 37 + d) + values[selection] * 17; + } + case 2 -> { + source.add(" trace = 0"); + source.add(" calls = 0"); + source.add(" let selected" + i + " = produce(" + a + ").y"); + source.add(" checksum += trace + selected" + i + " * 19 + calls * 23"); + expected += a * 37 + (a + 1) + (a + 1) * 19 + 23; + } + case 3 -> { + source.add(" trace = 0"); + source.add(" calls = 0"); + source.add(" if produce(" + a + ") != produce(" + b + ")"); + source.add(" checksum += " + (a == b ? 29 : 31)); + source.add(" else"); + source.add(" checksum += " + (a == b ? 31 : 29)); + source.add(" checksum += trace + calls * 37"); + expected += 31 + + (((a * 37 + (a + 1)) * 37 + b) * 37 + (b + 1)) + 2 * 37; + } + case 4 -> { + source.add(" let original" + i + " = new Holder()"); + source.add(" replacement = new Holder()"); + source.add(" current = original" + i); + source.add(" trace = 0"); + source.add(" current.value = retarget(" + a + ", " + b + ")"); + source.add(" checksum += original" + i + ".value.x * 41 + original" + i + + ".value.y * 43 + replacement.value.x + trace"); + expected += a * 41 + b * 43 + a * 37 + b; + } + case 5 -> { + source.add(" values[1] = pair(0, 0)"); + source.add(" values[2] = pair(0, 0)"); + source.add(" replacement = new Holder()"); + source.add(" currentIndex = 1"); + source.add(" trace = 0"); + source.add(" values[currentIndex] = retarget(" + a + ", " + b + ")"); + source.add(" checksum += values[1].x * 47 + values[1].y * 53 + values[2].x + trace"); + expected += a * 47 + b * 53 + a * 37 + b; + } + case 6 -> { + source.add(" mutable = " + a); + source.add(" trace = 0"); + source.add(" if pair(mutable, mark(" + a + ")) == mutatingPair(" + + c + ", " + a + ", " + a + ")"); + source.add(" checksum += 71"); + source.add(" else"); + source.add(" checksum += 67"); + source.add(" checksum += trace + mutable * 73"); + expected += 71 + ((a * 37 + a) * 37 + a) + c * 73; + } + case 7 -> { + source.add(" mutable = " + a); + source.add(" trace = 0"); + source.add(" checksum += scorePairs(pair(mutable, mark(" + a + + ")), mutatingPair(" + c + ", " + b + ", " + d + "))"); + source.add(" checksum += trace + mutable * 79"); + expected += a * 41 + a * 43 + b * 47 + d * 53 + + ((a * 37 + b) * 37 + d) + c * 79; + } + } + } + source.add(" trace = 0"); + source.add(" let recursiveResult = recursive(0)"); + source.add(" checksum += recursiveResult.x * 59 + recursiveResult.y * 61 + trace"); + expected += 7 * 59 + 61 + ((7 * 37 + 1) * 37 + 11); + source.add(" if checksum == " + expected); + source.add(" testSuccess()"); + + // executeProg validates the source-level IM interpreter; testLua additionally runs the + // scalarized output in Lua 5.3, making the generated program a deterministic differential test. + test().testLua(true).executeProg().lines(source.toArray(new String[0])); + String compiled = compiledLua("randomizedTupleEvaluationMatchesInterpreterAndLua"); + assertFalse(compiled.contains("tupleCopy")); + assertFalse(compiled.contains("tupleEquals")); + } + + @Test + public void tupleReturnSlotsAreSharedAcrossMultipleInterfaceRoots() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "interface First", + " function value(int seed) returns pair", + "interface Second", + " function value(int seed) returns pair", + "class Both implements First, Second", + " function value(int seed) returns pair", + " return pair(seed, seed + 1)", + "@noinline function fromFirst(First value) returns pair", + " return value.value(10)", + "@noinline function fromSecond(Second value) returns pair", + " return value.value(20)", + "init", + " let both = new Both()", + " let first = fromFirst(both)", + " let second = fromSecond(both)", + " if first == pair(10, 11) and second == pair(20, 21)", + " testSuccess()" + ); + + String compiled = compiledLua("tupleReturnSlotsAreSharedAcrossMultipleInterfaceRoots"); + assertFalse(compiled.contains("tupleCopy")); + assertFalse(compiled.contains("tupleEquals")); + } + + @Test + public void tupleSpecializedClassBindsNongenericInterfaceDispatch() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "interface Producer", + " function produce() returns pair", + "class GenericProducer implements Producer", + " pair stored", + " construct(pair value)", + " stored = value", + " function produce() returns pair", + " return stored", + "init", + " Producer producer = new GenericProducer(pair(4, 5))", + " if producer.produce() == pair(4, 5)", + " testSuccess()" + ); + + String compiled = compiledLua("tupleSpecializedClassBindsNongenericInterfaceDispatch"); + assertTrue(compiled.contains("GenericProducer_specialized")); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void tupleSpecializedClassPreservesRuntimeTypeOperations() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "interface Marker", + "class Box implements Marker", + " T value", + " construct(T initial)", + " value = initial", + "init", + " Marker box = new Box(pair(6, 7))", + " Marker plain = new Box(1)", + " if box instanceof Box and box.typeId == plain.typeId", + " testSuccess()" + ); + + String compiled = compiledLua("tupleSpecializedClassPreservesRuntimeTypeOperations"); + assertTrue(compiled.contains("Box_specialized")); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void tupleReturningCallsAreCapturedBeforeComparison() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "@noinline function value(int seed) returns pair", + " return pair(0, seed)", + "init", + " if value(1) != value(2) and not (value(1) == value(2))", + " testSuccess()" + ); + + String compiled = compiledLua("tupleReturningCallsAreCapturedBeforeComparison"); + assertFalse(compiled.contains("tupleCopy")); + assertFalse(compiled.contains("tupleEquals")); + } + + @Test + public void tupleReturningCallArgumentsAreStagedInOrder() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int trace", + "@noinline function produce(int seed) returns pair", + " trace = trace * 10 + seed", + " return pair(seed, seed + 10)", + "@noinline function consume(pair first, int middle, pair second) returns bool", + " return first == pair(1, 11) and middle == 7 and second == pair(2, 12)", + "function mark(int value) returns int", + " trace = trace * 10 + value", + " return value", + "init", + " if consume(produce(1), mark(7), produce(2)) and trace == 172", + " testSuccess()" + ); + + String compiled = compiledLua("tupleReturningCallArgumentsAreStagedInOrder"); + assertTrue("tuple arguments must be materialized before the scalar call", + compiled.contains("tuple_argument")); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void selectingLaterTupleComponentStillInvokesProducer() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "class Producer", + " int calls", + " @noinline function produce(int seed) returns pair", + " calls++", + " return pair(seed, seed + calls)", + "init", + " let producer = new Producer()", + " let selected = producer.produce(5).y", + " if selected == 6 and producer.calls == 1", + " testSuccess()" + ); + + String compiled = compiledLua("selectingLaterTupleComponentStillInvokesProducer"); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void tupleReturnStagesComponentsBeforeRecursiveSlotWrites() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "class Producer", + " @noinline function produce(int seed) returns pair", + " if seed == 0", + " return pair(7, produce(1).x)", + " return pair(seed, 99)", + "init", + " let result = new Producer().produce(0)", + " if result == pair(7, 1)", + " testSuccess()" + ); + + String compiled = compiledLua("tupleReturnStagesComponentsBeforeRecursiveSlotWrites"); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void tupleBundleCapturesEarlierReadsBeforeLaterPreludes() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int mutable", + "@noinline function mutate() returns pair", + " mutable = 9", + " return pair(5, 2)", + "@noinline function makeResult() returns pair", + " mutable = 7", + " return pair(mutable, mutate().y)", + "init", + " mutable = 5", + " pair assigned = pair(mutable, mutate().y)", + " let returned = makeResult()", + " mutable = 5", + " let compared = pair(mutable, 2) == mutate()", + " if assigned == pair(5, 2) and returned == pair(7, 2)", + " and compared and mutable == 9", + " testSuccess()" + ); + + String compiled = compiledLua("tupleBundleCapturesEarlierReadsBeforeLaterPreludes"); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void tupleSelectionPreservesLeftToRightEvaluation() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int trace", + "function mark(int value) returns int", + " trace = trace * 10 + value", + " return value", + "init", + " let selected = pair(mark(1), mark(2)).x", + " if selected == 1 and trace == 12", + " testSuccess()" + ); + + String compiled = compiledLua("tupleSelectionPreservesLeftToRightEvaluation"); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void discardedTupleComponentsThatCanFailAreStillEvaluated() { + String compiled = compileLuaWithRunArgs( + "discardedTupleComponentsThatCanFailAreStillEvaluated", + new RunArgs().with("-lua"), + "package Test", + "tuple pair(int x, int y)", + "class Box", + " int value", + "Box nullable", + "init", + " let selected = pair(1, nullable.value).x" + ); + + assertTrue("discarded member access must still be evaluated so null access can fail", + java.util.regex.Pattern.compile( + "__wurst_tuple_discard_\\d+\\([^\\n]*Box_value_storage\\[[^]]*nullable]\\)") + .matcher(compiled).find()); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void tupleAssignmentCapturesLvalueBeforeRhs() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "class Holder", + " pair value", + "Holder current", + "Holder replacement", + "int currentIndex = 1", + "pair array values", + "function changeTargets() returns pair", + " current = replacement", + " currentIndex = 2", + " return pair(3, 4)", + "init", + " let original = new Holder()", + " replacement = new Holder()", + " current = original", + " current.value = changeTargets()", + " current = original", + " currentIndex = 1", + " values[currentIndex] = changeTargets()", + " if original.value == pair(3, 4) and replacement.value == pair(0, 0)", + " and values[1] == pair(3, 4) and values[2] == pair(0, 0)", + " testSuccess()" + ); + + String compiled = compiledLua("tupleAssignmentCapturesLvalueBeforeRhs"); + assertFalse(compiled.contains("tupleCopy")); + } + + @Test + public void tupleSpecializationPreservesExplicitGenericStaticOwner() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "class Box", + " static int counter", + " static function setCounter(int value)", + " counter = value", + " static function incrementCounter()", + " counter++", + " static function getCounter() returns int", + " return counter", + "function touch(T value)", + " Box.incrementCounter()", + "init", + " Box.setCounter(10)", + " Box.setCounter(100)", + " touch(pair(1, 2))", + " if Box.getCounter() == 11 and Box.getCounter() == 100", + " testSuccess()" + ); + + String compiled = compiledLua("tupleSpecializationPreservesExplicitGenericStaticOwner"); + assertFalse(compiled.contains("tupleCopy")); + } + @Test public void compiletimeGenericArrayReplayLeavesAreSplit() { String compiled = compileLuaWithRunArgs( @@ -900,7 +1428,7 @@ public void primitiveArrayReadsDoNotMaterializeEntries() throws IOException { assertFalse("primitive array default reads must not write back into the array table", compiled.substring(fnStart, fnEnd).contains("=")); - assertTrue("tuple array defaults must still be lazily materialized per-slot for identity", + assertFalse("tuple arrays are value types and must be split into scalar arrays", compiled.contains("function __wurst_arrIndex(")); } From fe872697cf7441a2afd6c56462da714d48437994 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 11:54:11 +0200 Subject: [PATCH 02/12] Retain nominal metadata for tuple specializations --- .../lua/translation/LuaTranslator.java | 15 +++++------- .../lua/translation/RemoveGarbage.java | 14 ++++++++++- .../tests/LuaBackendAuditTests.java | 23 +++++++++++++++++++ 3 files changed, 42 insertions(+), 10 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 8df6fc5fa..b4fc3b2d4 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 @@ -1062,12 +1062,9 @@ private void initClassTables(ImClass c) { )); // set typeid metadata: - ImClass typeIdClass = c; - ImTranslator.Specialisation specialization = imTr.specialisationOf(c); - if (specialization != null && specialization.original() instanceof ImClass original) { - // Targeted Lua specialization preserves the nominal identity of the erased class. - typeIdClass = original; - } + // Targeted Lua specialization changes storage, not nominal identity. Garbage reachability + // retains this canonical metadata dependency before emission. + ImClass typeIdClass = imTr.canonical(c); deferMainInit(LuaAst.LuaAssignment(LuaAst.LuaExprFieldAccess( LuaAst.LuaExprVarAccess(classVar), ExprTranslation.TYPE_ID), @@ -1486,11 +1483,11 @@ private void collectSuperClasses(LuaTableFields superClasses, ImClass c, Set functions = new HashSet<>(); private final Set methods = new HashSet<>(); // methods that will be added once the class is used: @@ -26,6 +27,10 @@ private static class Used { private final Set classes = new HashSet<>(); private final Set vars = new HashSet<>(); + private Used(ImTranslator translator) { + this.translator = translator; + } + public void addMethod(ImMethod m) { methods.add(m); } @@ -65,6 +70,13 @@ public void addVar(ImVar var) { public void addClass(ImClass c) { classes.add(c); + ImClass nominalClass = translator.canonical(c); + if (nominalClass != c) { + // A targeted specialization has a distinct storage layout but keeps the source + // class's nominal type id and instanceof identity. The canonical class is therefore + // a real metadata dependency even when no source expression names it directly. + visitClass(nominalClass, this); + } Collection imMethods = waitingMethods.get(c); Iterator it = imMethods.iterator(); while (it.hasNext()) { @@ -76,7 +88,7 @@ public void addClass(ImClass c) { } public static void removeGarbage(ImProg prog, ImTranslator translator) { - Used used = new Used(); + Used used = new Used(translator); for (ImFunction f : ImHelper.calculateFunctionsOfProg(prog)) { if (f.getName().equals("main") || f.getName().equals("config")) { 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 2f6627095..12b41dea3 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 @@ -451,6 +451,29 @@ public void tupleSpecializedClassPreservesRuntimeTypeOperations() throws IOExcep assertFalse(compiled.contains("tupleCopy")); } + @Test + public void tupleSpecializedClassRetainsNominalMetadataWhenItIsTheOnlyReachableForm() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "interface Marker", + "class Box implements Marker", + " T value", + " construct(T initial)", + " value = initial", + "init", + " Marker box = new Box(pair(6, 7))", + " if box != null", + " testSuccess()" + ); + + String compiled = compiledLua( + "tupleSpecializedClassRetainsNominalMetadataWhenItIsTheOnlyReachableForm"); + assertTrue(compiled.contains("Box_specialized")); + assertFalse(compiled.contains("tupleCopy")); + } + @Test public void tupleReturningCallsAreCapturedBeforeComparison() throws IOException { test().testLua(true).executeProg().lines( From 2af7430b4dcdfe83d20dbd08ad53624330645da8 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 12:06:26 +0200 Subject: [PATCH 03/12] Preserve tuple address order deterministically --- .../imtranslation/EliminateGenerics.java | 3 +- .../imtranslation/EliminateTuples.java | 13 ++++- .../tests/LuaBackendAuditTests.java | 57 +++++++++++++++++++ 3 files changed, 69 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 0a64ee6bf..67725803f 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 @@ -58,7 +58,8 @@ public class EliminateGenerics { // NEW: Track which global vars belong to which generic class // This helps us know which globals need specialization - private final Map globalToClass = new HashMap<>(); + /** Generic statics in source/program order; specialization emission must be deterministic. */ + private final Map globalToClass = new LinkedHashMap<>(); // NEW: which functions touch generic globals (identity-based) private final Map> functionToGenericGlobalOwners = new IdentityHashMap<>(); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java index 751a2650c..4b2e72a13 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java @@ -455,7 +455,10 @@ public void visit(ImMemberAccess ma) { super.visit(ma); if (ma.attrTyp() instanceof ImTupleType) { ImStmts stmts = JassIm.ImStmts(); - ImExpr receiver = captureOnceIfNeeded(ma.getReceiver(), "tupleReceiver", stmts, f); + boolean indexesAreEffectful = ma.getIndexes().stream() + .anyMatch(SideEffectAnalyzer::quickcheckHasSideeffects); + ImExpr receiver = captureOnceIfNeeded(ma.getReceiver(), "tupleReceiver", stmts, + f, indexesAreEffectful); ImExprs indexes = captureIndexesOnceIfNeeded(ma.getIndexes(), stmts, f); VarsForTupleResult vars = translator.getVarsForTuple(ma.getVar()); ImExpr replacement = vars.map( @@ -512,8 +515,9 @@ public void visit(ImMethodCall mc) { }); } - private static ImExpr captureOnceIfNeeded(ImExpr expr, String name, ImStmts stmts, ImFunction f) { - if (!SideEffectAnalyzer.quickcheckHasSideeffects(expr)) { + private static ImExpr captureOnceIfNeeded(ImExpr expr, String name, ImStmts stmts, ImFunction f, + boolean forceCapture) { + if (!forceCapture && !SideEffectAnalyzer.quickcheckHasSideeffects(expr)) { return expr; } ImVar temp = JassIm.ImVar(expr.attrTrace(), expr.attrTyp(), name, false); @@ -894,6 +898,9 @@ private static ImLExpr captureLvalueAddress(ImLExpr lvalue, ImStmts stmts, ImFun private static void captureLvalueIndexes(ImExprs indexes, ImStmts stmts, ImFunction f) { for (int i = 0; i < indexes.size(); i++) { ImExpr index = indexes.get(i); + if (isImmutableValue(index)) { + continue; + } index.setParent(null); indexes.set(i, captureValue(index, "tuple_lvalue_index", stmts, f)); } 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 12b41dea3..5db660d54 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 @@ -663,6 +663,39 @@ public void tupleAssignmentCapturesLvalueBeforeRhs() throws IOException { assertFalse(compiled.contains("tupleCopy")); } + @Test + public void tupleFieldReadCapturesReceiverBeforeEffectfulIndex() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "class Holder", + " pair array[8] values", + "Holder current", + "Holder replacement", + "function retarget() returns int", + " current = replacement", + " return 1", + "init", + " let original = new Holder()", + " replacement = new Holder()", + " original.values[1] = pair(3, 4)", + " replacement.values[1] = pair(8, 9)", + " current = original", + " let result = current.values[retarget()]", + " if result == pair(3, 4) and current == replacement", + " testSuccess()" + ); + + String compiled = compiledLua("tupleFieldReadCapturesReceiverBeforeEffectfulIndex"); + java.util.regex.Matcher capture = java.util.regex.Pattern.compile( + "(tupleReceiver\\w*) = Test_current\\s+(tupleIndex\\w*) = retarget\\(\\)" + + "\\s+[^\\n]*_values_x_storage\\[\\1]\\[\\2]") + .matcher(compiled); + assertTrue("receiver must be captured before the index retargets it", capture.find()); + assertFalse(compiled.contains("tupleCopy")); + } + @Test public void tupleSpecializationPreservesExplicitGenericStaticOwner() throws IOException { test().testLua(true).executeProg().lines( @@ -691,6 +724,30 @@ public void tupleSpecializationPreservesExplicitGenericStaticOwner() throws IOEx assertFalse(compiled.contains("tupleCopy")); } + @Test + public void tupleSpecializedStaticsEmitDeterministically() { + String[] source = { + "package Test", + "tuple pair(int x, int y)", + "class Box", + " static T first", + " static T second", + " static T third", + " static function set(T a, T b, T c)", + " first = a", + " second = b", + " third = c", + "init", + " Box.set(pair(1, 2), pair(3, 4), pair(5, 6))" + }; + RunArgs runArgs = new RunArgs().with("-lua"); + String first = compileLuaWithRunArgs("tupleSpecializedStaticsEmitDeterministically1", + runArgs, source); + String second = compileLuaWithRunArgs("tupleSpecializedStaticsEmitDeterministically2", + runArgs, source); + assertEquals("tuple-specialized statics must emit byte-identically", first, second); + } + @Test public void compiletimeGenericArrayReplayLeavesAreSplit() { String compiled = compileLuaWithRunArgs( From d1763d3a04a28eb54da9d626c63b6aaaeed5a801 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 12:14:59 +0200 Subject: [PATCH 04/12] Preserve eager tuple comparison evaluation --- .../imtranslation/EliminateTuples.java | 52 ++++++++++++++----- .../tests/LuaBackendAuditTests.java | 22 ++++++++ 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java index 4b2e72a13..c15e50a48 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java @@ -169,7 +169,8 @@ private static void eliminateTuplesFunc(ImFunction f, final ImTranslator transla tryStep(f, translator, (stmts, tr, fn) -> removeTupleSelections(stmts, tr, fn, discardEvaluation)); tryStep(f, translator, EliminateTuples::normalizeTuplesInStatementExprs); - tryStep(f, translator, (stmts, translator1, fn) -> removeTupleExprs(0, stmts, translator1, fn)); + tryStep(f, translator, (stmts, translator1, fn) -> + removeTupleExprs(0, stmts, translator1, fn, discardEvaluation)); } @@ -588,13 +589,14 @@ private static ImTupleExpr normalizeStatementExpr(ImStatementExpr se, ImTranslat * - Assignments: Become several assignments * - In Return: Use temp returns */ - private static void removeTupleExprs(int posHint, Element elem, ImTranslator translator, ImFunction f) { + private static void removeTupleExprs(int posHint, Element elem, ImTranslator translator, + ImFunction f, DiscardEvaluation discardEvaluation) { if (elem.getParent() == null) { throw new RuntimeException("elem not used: " + elem); } for (int i = 0; i < elem.size(); i++) { Element child = elem.get(i); - removeTupleExprs(i, child, translator, f); + removeTupleExprs(i, child, translator, f, discardEvaluation); } Replacer replacer = new Replacer(); for (int i = 0; i < elem.size(); i++) { @@ -615,7 +617,7 @@ private static void removeTupleExprs(int posHint, Element elem, ImTranslator tra ImExprs exprs = (ImExprs) elem; if (exprs.getParent() instanceof ImOperatorCall) { ImOperatorCall opCall = (ImOperatorCall) exprs.getParent(); - handleTupleInOpCall(replacer, opCall, f); + handleTupleInOpCall(replacer, opCall, f, discardEvaluation); return; } else if (exprs.getParent() instanceof ImFunctionCall || exprs.getParent() instanceof ImMethodCall) { @@ -688,7 +690,8 @@ private static ImStatementExpr stageTupleCallArguments(ImExpr call, ImExprs argu return JassIm.ImStatementExpr(evaluation, (ImExpr) call.copy()); } - private static void handleTupleInOpCall(Replacer replacer, ImOperatorCall opCall, ImFunction f) { + private static void handleTupleInOpCall(Replacer replacer, ImOperatorCall opCall, ImFunction f, + DiscardEvaluation discardEvaluation) { if (opCall.getParent() == null) { throw new RuntimeException("opCall not used: " + opCall); } @@ -698,8 +701,10 @@ private static void handleTupleInOpCall(Replacer replacer, ImOperatorCall opCall ImStmts evaluation = JassIm.ImStmts(); boolean forceOrder = needsOrderedCapture(left) || needsOrderedCapture(right); - List leftComponents = captureTupleComponents(left, evaluation, f, forceOrder); - List rightComponents = captureTupleComponents(right, evaluation, f, forceOrder); + List leftComponents = captureTupleComponents(left, evaluation, f, forceOrder, + discardEvaluation); + List rightComponents = captureTupleComponents(right, evaluation, f, forceOrder, + discardEvaluation); if (leftComponents.size() != rightComponents.size()) { throw new CompileError(opCall.attrTrace(), "Cannot compare tuples with different arity."); } @@ -748,9 +753,15 @@ private static void handleTupleInOpCall(Replacer replacer, ImOperatorCall opCall } private static List captureTupleComponents(ImTupleExpr tuple, ImStmts evaluation, - ImFunction f, boolean forceOrder) { - OrderedBundle bundle = lowerBundle(tuple, f, forceOrder, "tuple_compare"); + ImFunction f, boolean forceOrder, + DiscardEvaluation discardEvaluation) { + OrderedBundle bundle = lowerBundle(tuple, f, forceOrder, "tuple_compare", true); bundle.appendPreludeTo(evaluation); + for (int i = 0; i < bundle.values.size(); i++) { + if (bundle.requiresEagerEvaluation.get(i)) { + evaluation.add(discardEvaluation.call(bundle.values.get(i).copy())); + } + } return bundle.values; } @@ -922,6 +933,7 @@ private static ImExpr captureValue(ImExpr value, String name, ImStmts stmts, ImF private static final class OrderedBundle { private final ImStmts prelude = JassIm.ImStmts(); private final List values = new ArrayList<>(); + private final List requiresEagerEvaluation = new ArrayList<>(); private void appendPreludeTo(ImStmts target) { for (ImStmt statement : prelude.removeAll()) { @@ -933,33 +945,45 @@ private void appendPreludeTo(ImStmts target) { private static OrderedBundle lowerBundle(ImExpr expression, ImFunction f, boolean forceOrder, String temporaryName) { + return lowerBundle(expression, f, forceOrder, temporaryName, false); + } + + private static OrderedBundle lowerBundle(ImExpr expression, ImFunction f, boolean forceOrder, + String temporaryName, + boolean capturePotentiallyFailingValues) { OrderedBundle result = new OrderedBundle(); lowerBundleInto(expression, f, forceOrder || needsOrderedCapture(expression), temporaryName, - result); + capturePotentiallyFailingValues, result); return result; } private static void lowerBundleInto(ImExpr expression, ImFunction f, boolean capture, - String temporaryName, OrderedBundle result) { + String temporaryName, boolean capturePotentiallyFailingValues, + OrderedBundle result) { ImExpr value = extractSideEffect(expression, result.prelude); if (value instanceof ImNull nullValue && nullValue.getType() instanceof ImTupleType) { lowerBundleInto(ImHelper.defaultValueForComplexType(nullValue.getType()), f, capture, - temporaryName, result); + temporaryName, capturePotentiallyFailingValues, result); return; } if (value instanceof ImTupleExpr tuple) { for (ImExpr component : new ArrayList<>(tuple.getExprs())) { - lowerBundleInto(component, f, capture, temporaryName, result); + lowerBundleInto(component, f, capture, temporaryName, + capturePotentiallyFailingValues, result); } return; } value.setParent(null); - if (capture && !isImmutableValue(value)) { + boolean requiresEagerEvaluation = capturePotentiallyFailingValues + && !isTriviallyDiscardable(value) + && !SideEffectAnalyzer.quickcheckHasSideeffects(value); + if ((capture || requiresEagerEvaluation) && !isImmutableValue(value)) { result.values.add(captureValue(value, temporaryName, result.prelude, f)); } else { result.values.add(value); } + result.requiresEagerEvaluation.add(requiresEagerEvaluation); } private static boolean needsOrderedCapture(ImExpr expression) { 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 5db660d54..0d486e2d9 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 @@ -630,6 +630,28 @@ public void discardedTupleComponentsThatCanFailAreStillEvaluated() { assertFalse(compiled.contains("tupleCopy")); } + @Test + public void tupleComparisonEagerlyEvaluatesPotentiallyFailingComponents() { + String compiled = compileLuaWithRunArgs( + "tupleComparisonEagerlyEvaluatesPotentiallyFailingComponents", + new RunArgs().with("-lua"), + "package Test", + "tuple pair(int x, int y)", + "class Box", + " int value", + "Box nullable", + "init", + " let equal = pair(1, nullable.value) == pair(2, 0)" + ); + + assertTrue("comparison operands must cross the eager-evaluation barrier before and/or", + java.util.regex.Pattern.compile( + "__wurst_tuple_discard_\\d+\\([^\\n]*tuple_compare[^\\n]*\\)") + .matcher(compiled).find()); + assertFalse(compiled.contains("tupleCopy")); + assertFalse(compiled.contains("tupleEquals")); + } + @Test public void tupleAssignmentCapturesLvalueBeforeRhs() throws IOException { test().testLua(true).executeProg().lines( From 6d86f5f7feba22c13e4de52fbdbc077fc4aaba61 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 12:34:16 +0200 Subject: [PATCH 05/12] Remove phantom generic static initialization --- .../peeeq/wurstio/WurstCompilerJassImpl.java | 3 + .../lua/translation/RemoveGarbage.java | 112 ++++++++++++++++-- .../tests/LuaBackendAuditTests.java | 45 +++++++ 3 files changed, 153 insertions(+), 7 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 107088183..5043a07f4 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 @@ -879,6 +879,9 @@ public LuaCompilationUnit transformProgToLua() { beginPhase(2, "Specialize generics for Lua-only concrete operations"); new EliminateGenerics(getImTranslator(), getImProg()) .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()); timeTaker.endPhase(); } if (runArgs.isNoDebugMessages()) { 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 d570e09b4..684d7e3c5 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 @@ -8,8 +8,12 @@ import de.peeeq.wurstscript.validation.TRVEHelper; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; /** @@ -88,13 +92,8 @@ public void addClass(ImClass c) { } public static void removeGarbage(ImProg prog, ImTranslator translator) { - Used used = new Used(translator); - for (ImFunction f : ImHelper.calculateFunctionsOfProg(prog)) { - if (f.getName().equals("main") - || f.getName().equals("config")) { - visitFunction(f, used); - } - } + removePhantomGenericStaticInitializers(prog, translator); + Used used = collectUsed(prog, translator); prog.getClasses().removeIf(c -> !used.getClasses().contains(c)); prog.getGlobals().removeIf(g -> !used.getVars().contains(g) && !TRVEHelper.protectedVariables.contains(g.getName())); @@ -119,6 +118,105 @@ public static void removeGarbage(ImProg prog, ImTranslator translator) { } + private static Used collectUsed(ImProg prog, ImTranslator translator) { + Used used = new Used(translator); + for (ImFunction f : ImHelper.calculateFunctionsOfProg(prog)) { + if (f.getName().equals("main") + || f.getName().equals("config")) { + visitFunction(f, used); + } + } + return used; + } + + public static void removePhantomGenericStaticInitializers(ImProg prog, ImTranslator translator) { + while (removePhantomGenericStaticInitializersOnce(prog, translator, + collectUsed(prog, translator))) { + // Removing one initializer can make a generic static referenced by it unreachable. + } + } + + /** + * A targeted generic specialization owns a copied static initializer. The erased initializer is + * not an independent runtime instantiation: when no live code uses its original static, keeping + * its side effects would initialize a phantom object and run the source initializer twice. + */ + private static boolean removePhantomGenericStaticInitializersOnce(ImProg prog, + ImTranslator translator, + Used used) { + Set originalsWithSpecializations = new LinkedHashSet<>(); + for (ImVar global : prog.getGlobals()) { + ImTranslator.Specialisation specialization = translator.specialisationOf(global); + if (specialization != null && specialization.original() instanceof ImVar original + && translator.genericStaticOwnerOf(original) != null) { + originalsWithSpecializations.add(original); + } + } + for (ImVar original : originalsWithSpecializations) { + List initializers = prog.getGlobalInits().get(original); + if (hasUseOutsideOwnInitializer(original, initializers, used)) { + continue; + } + initializers = prog.getGlobalInits().remove(original); + if (initializers == null) { + continue; + } + for (ImSet initializer : initializers) { + if (!(initializer.getParent() instanceof ImStmts statements)) { + throw new IllegalStateException("Global initializer is not attached to an ImStmts node."); + } + statements.remove(initializer); + } + return true; + } + return false; + } + + private static boolean hasUseOutsideOwnInitializer(ImVar global, List initializers, + Used used) { + Set initializerSet = initializers == null + ? Collections.emptySet() + : Collections.newSetFromMap(new IdentityHashMap<>()); + if (initializers != null) { + initializerSet.addAll(initializers); + } + boolean[] found = {false}; + for (ImFunction function : used.getFunctions()) { + function.accept(new Element.DefaultVisitor() { + @Override + public void visit(ImVarAccess access) { + if (access.getVar() == global && !isInsideInitializer(access, initializerSet)) { + found[0] = true; + } + super.visit(access); + } + + @Override + public void visit(ImVarArrayAccess access) { + if (access.getVar() == global && !isInsideInitializer(access, initializerSet)) { + found[0] = true; + } + super.visit(access); + } + }); + if (found[0]) { + return true; + } + } + return false; + } + + private static boolean isInsideInitializer(Element access, Set initializers) { + Element current = access; + while (current.getParent() instanceof Element parent) { + if (parent instanceof ImSet set && initializers.contains(set)) { + return true; + } + current = parent; + } + return false; + } + private static void visitFunction(ImFunction f, Used used) { if (used.getFunctions().contains(f)) { 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 0d486e2d9..267a70157 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 @@ -770,6 +770,51 @@ public void tupleSpecializedStaticsEmitDeterministically() { assertEquals("tuple-specialized statics must emit byte-identically", first, second); } + @Test + public void tupleSpecializedStaticInitializerRunsOnceWithoutErasedInstantiation() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " static function get() returns int", + " return value", + "init", + " if Box.get() == 1 and bumps == 1", + " testSuccess()" + ); + + String compiled = compiledLua( + "tupleSpecializedStaticInitializerRunsOnceWithoutErasedInstantiation"); + assertEquals("only the live tuple instantiation may call the static initializer", + 2, countOccurrences(compiled, "bump()")); // one function declaration plus one call + } + + @Test + public void tupleSpecializedStaticKeepsLiveErasedInitializer() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " static function get() returns int", + " return value", + "init", + " if Box.get() == 1 and Box.get() == 2 and bumps == 2", + " testSuccess()" + ); + } + @Test public void compiletimeGenericArrayReplayLeavesAreSplit() { String compiled = compileLuaWithRunArgs( From 247b0b7c8b8a805dd87a9dcb3cf2a998e493ce8f Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 12:45:21 +0200 Subject: [PATCH 06/12] Mark live generic static initializer graphs --- .../lua/translation/RemoveGarbage.java | 121 +++++++----------- .../tests/LuaBackendAuditTests.java | 26 ++++ 2 files changed, 75 insertions(+), 72 deletions(-) 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 684d7e3c5..f991e9ece 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 @@ -12,8 +12,10 @@ import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -30,9 +32,11 @@ private static class Used { private final Multimap waitingMethods = HashMultimap.create(); private final Set classes = new HashSet<>(); private final Set vars = new HashSet<>(); + private final Set ignoredInitializers; - private Used(ImTranslator translator) { + private Used(ImTranslator translator, Set ignoredInitializers) { this.translator = translator; + this.ignoredInitializers = ignoredInitializers; } public void addMethod(ImMethod m) { @@ -119,7 +123,12 @@ public static void removeGarbage(ImProg prog, ImTranslator translator) { } private static Used collectUsed(ImProg prog, ImTranslator translator) { - Used used = new Used(translator); + return collectUsed(prog, translator, Collections.emptySet()); + } + + private static Used collectUsed(ImProg prog, ImTranslator translator, + Set ignoredInitializers) { + Used used = new Used(translator, ignoredInitializers); for (ImFunction f : ImHelper.calculateFunctionsOfProg(prog)) { if (f.getName().equals("main") || f.getName().equals("config")) { @@ -130,91 +139,52 @@ private static Used collectUsed(ImProg prog, ImTranslator translator) { } public static void removePhantomGenericStaticInitializers(ImProg prog, ImTranslator translator) { - while (removePhantomGenericStaticInitializersOnce(prog, translator, - collectUsed(prog, translator))) { - // Removing one initializer can make a generic static referenced by it unreachable. - } - } - - /** - * A targeted generic specialization owns a copied static initializer. The erased initializer is - * not an independent runtime instantiation: when no live code uses its original static, keeping - * its side effects would initialize a phantom object and run the source initializer twice. - */ - private static boolean removePhantomGenericStaticInitializersOnce(ImProg prog, - ImTranslator translator, - Used used) { - Set originalsWithSpecializations = new LinkedHashSet<>(); + Map> candidates = new LinkedHashMap<>(); for (ImVar global : prog.getGlobals()) { ImTranslator.Specialisation specialization = translator.specialisationOf(global); if (specialization != null && specialization.original() instanceof ImVar original && translator.genericStaticOwnerOf(original) != null) { - originalsWithSpecializations.add(original); + List initializers = prog.getGlobalInits().get(original); + if (initializers != null) { + candidates.putIfAbsent(original, initializers); + } } } - for (ImVar original : originalsWithSpecializations) { - List initializers = prog.getGlobalInits().get(original); - if (hasUseOutsideOwnInitializer(original, initializers, used)) { - continue; + + // First ignore every erased initializer, then mark the originals referenced by real roots. + // Re-enable initializers of marked originals until their transitive dependencies are marked. + // This is graph reachability rather than deletion order, so unreachable initializer cycles + // cannot keep themselves alive. + Set liveOriginals = new LinkedHashSet<>(); + boolean changed; + do { + Set ignored = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Map.Entry> candidate : candidates.entrySet()) { + if (!liveOriginals.contains(candidate.getKey())) { + ignored.addAll(candidate.getValue()); + } + } + Used used = collectUsed(prog, translator, ignored); + changed = false; + for (ImVar original : candidates.keySet()) { + if (used.getVars().contains(original) && liveOriginals.add(original)) { + changed = true; + } } - initializers = prog.getGlobalInits().remove(original); - if (initializers == null) { + } while (changed); + + for (Map.Entry> candidate : candidates.entrySet()) { + if (liveOriginals.contains(candidate.getKey())) { continue; } - for (ImSet initializer : initializers) { + prog.getGlobalInits().remove(candidate.getKey()); + for (ImSet initializer : candidate.getValue()) { if (!(initializer.getParent() instanceof ImStmts statements)) { throw new IllegalStateException("Global initializer is not attached to an ImStmts node."); } statements.remove(initializer); } - return true; - } - return false; - } - - private static boolean hasUseOutsideOwnInitializer(ImVar global, List initializers, - Used used) { - Set initializerSet = initializers == null - ? Collections.emptySet() - : Collections.newSetFromMap(new IdentityHashMap<>()); - if (initializers != null) { - initializerSet.addAll(initializers); - } - boolean[] found = {false}; - for (ImFunction function : used.getFunctions()) { - function.accept(new Element.DefaultVisitor() { - @Override - public void visit(ImVarAccess access) { - if (access.getVar() == global && !isInsideInitializer(access, initializerSet)) { - found[0] = true; - } - super.visit(access); - } - - @Override - public void visit(ImVarArrayAccess access) { - if (access.getVar() == global && !isInsideInitializer(access, initializerSet)) { - found[0] = true; - } - super.visit(access); - } - }); - if (found[0]) { - return true; - } - } - return false; - } - - private static boolean isInsideInitializer(Element access, Set initializers) { - Element current = access; - while (current.getParent() instanceof Element parent) { - if (parent instanceof ImSet set && initializers.contains(set)) { - return true; - } - current = parent; } - return false; } private static void visitFunction(ImFunction f, Used used) { @@ -225,6 +195,13 @@ private static void visitFunction(ImFunction f, Used used) { visitType(f.getReturnType(), used); f.accept(new Element.DefaultVisitor() { + @Override + public void visit(ImSet e) { + if (!used.ignoredInitializers.contains(e)) { + super.visit(e); + } + } + @Override public void visit(ImFunctionCall e) { super.visit(e); 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 267a70157..b4fa27205 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 @@ -815,6 +815,32 @@ public void tupleSpecializedStaticKeepsLiveErasedInitializer() { ); } + @Test + public void tupleSpecializedStaticInitializerCycleDoesNotRootErasedCopy() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int a = b + bump()", + " static int b = a", + " static function get() returns int", + " return a + b", + "init", + " if Box.get() == 2 and bumps == 1", + " testSuccess()" + ); + + String compiled = compiledLua( + "tupleSpecializedStaticInitializerCycleDoesNotRootErasedCopy"); + assertEquals("an unreachable erased initializer cycle must not execute", + 2, countOccurrences(compiled, "bump()")); // one function declaration plus one call + } + @Test public void compiletimeGenericArrayReplayLeavesAreSplit() { String compiled = compileLuaWithRunArgs( From 937ec83d3350f035167e8dd9398e98da204901f4 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 13:08:41 +0200 Subject: [PATCH 07/12] Keep initializers for live erased classes --- .../lua/translation/RemoveGarbage.java | 22 +++++++++++++++---- .../tests/LuaBackendAuditTests.java | 21 ++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) 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 f991e9ece..6518c7d8c 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 @@ -31,6 +31,8 @@ private static class Used { // methods that will be added once the class is used: private final Multimap waitingMethods = HashMultimap.create(); private final Set classes = new HashSet<>(); + /** Classes named directly by reachable code, excluding canonical metadata dependencies. */ + private final Set directClasses = new HashSet<>(); private final Set vars = new HashSet<>(); private final Set ignoredInitializers; @@ -64,6 +66,10 @@ public Set getClasses() { return classes; } + public Set getDirectClasses() { + return directClasses; + } + public Set getVars() { return vars; } @@ -83,7 +89,7 @@ public void addClass(ImClass c) { // A targeted specialization has a distinct storage layout but keeps the source // class's nominal type id and instanceof identity. The canonical class is therefore // a real metadata dependency even when no source expression names it directly. - visitClass(nominalClass, this); + visitClass(nominalClass, this, false); } Collection imMethods = waitingMethods.get(c); Iterator it = imMethods.iterator(); @@ -96,7 +102,6 @@ public void addClass(ImClass c) { } public static void removeGarbage(ImProg prog, ImTranslator translator) { - removePhantomGenericStaticInitializers(prog, translator); Used used = collectUsed(prog, translator); prog.getClasses().removeIf(c -> !used.getClasses().contains(c)); @@ -167,7 +172,9 @@ public static void removePhantomGenericStaticInitializers(ImProg prog, ImTransla Used used = collectUsed(prog, translator, ignored); changed = false; for (ImVar original : candidates.keySet()) { - if (used.getVars().contains(original) && liveOriginals.add(original)) { + ImClass owner = translator.genericStaticOwnerOf(original); + if ((used.getVars().contains(original) || used.getDirectClasses().contains(owner)) + && liveOriginals.add(original)) { changed = true; } } @@ -298,12 +305,19 @@ private static void visitMethod(ImMethod m, Used used) { } private static void visitClass(ImClass c, Used used) { + visitClass(c, used, true); + } + + private static void visitClass(ImClass c, Used used, boolean direct) { + if (direct) { + used.getDirectClasses().add(c); + } if (used.getClasses().contains(c)) { return; } used.addClass(c); for (ImClassType superClass : c.getSuperClasses()) { - visitClass(superClass.getClassDef(), used); + visitClass(superClass.getClassDef(), used, direct); } } 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 b4fa27205..cd162234f 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 @@ -815,6 +815,27 @@ public void tupleSpecializedStaticKeepsLiveErasedInitializer() { ); } + @Test + public void tupleSpecializedStaticKeepsInitializerForConstructedErasedClass() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " construct()", + "init", + " new Box()", + " new Box()", + " if bumps == 2", + " testSuccess()" + ); + } + @Test public void tupleSpecializedStaticInitializerCycleDoesNotRootErasedCopy() throws IOException { test().testLua(true).executeProg().lines( From 552451941c04e2995307fcfc8702a3e2c1f32499 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 13:19:18 +0200 Subject: [PATCH 08/12] Root erased initializers from allocations --- .../lua/translation/RemoveGarbage.java | 31 ++++++++++--------- .../tests/LuaBackendAuditTests.java | 25 +++++++++++++++ 2 files changed, 42 insertions(+), 14 deletions(-) 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 6518c7d8c..695cc2c09 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 @@ -31,8 +31,8 @@ private static class Used { // methods that will be added once the class is used: private final Multimap waitingMethods = HashMultimap.create(); private final Set classes = new HashSet<>(); - /** Classes named directly by reachable code, excluding canonical metadata dependencies. */ - private final Set directClasses = new HashSet<>(); + /** Classes which reachable code actually allocates, excluding nominal type-only references. */ + private final Set instantiatedClasses = new HashSet<>(); private final Set vars = new HashSet<>(); private final Set ignoredInitializers; @@ -66,8 +66,8 @@ public Set getClasses() { return classes; } - public Set getDirectClasses() { - return directClasses; + public Set getInstantiatedClasses() { + return instantiatedClasses; } public Set getVars() { @@ -89,7 +89,7 @@ public void addClass(ImClass c) { // A targeted specialization has a distinct storage layout but keeps the source // class's nominal type id and instanceof identity. The canonical class is therefore // a real metadata dependency even when no source expression names it directly. - visitClass(nominalClass, this, false); + visitClass(nominalClass, this); } Collection imMethods = waitingMethods.get(c); Iterator it = imMethods.iterator(); @@ -99,6 +99,15 @@ public void addClass(ImClass c) { it.remove(); } } + + public void addInstantiatedClass(ImClass c) { + if (!instantiatedClasses.add(c)) { + return; + } + for (ImClassType superClass : c.getSuperClasses()) { + addInstantiatedClass(superClass.getClassDef()); + } + } } public static void removeGarbage(ImProg prog, ImTranslator translator) { @@ -173,7 +182,7 @@ 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.getDirectClasses().contains(owner)) + if ((used.getVars().contains(original) || used.getInstantiatedClasses().contains(owner)) && liveOriginals.add(original)) { changed = true; } @@ -248,6 +257,7 @@ public void visit(ImVarArrayAccess e) { @Override public void visit(ImAlloc e) { super.visit(e); + used.addInstantiatedClass(e.getClazz().getClassDef()); visitClass(e.getClazz().getClassDef(), used); } @@ -305,19 +315,12 @@ private static void visitMethod(ImMethod m, Used used) { } private static void visitClass(ImClass c, Used used) { - visitClass(c, used, true); - } - - private static void visitClass(ImClass c, Used used, boolean direct) { - if (direct) { - used.getDirectClasses().add(c); - } if (used.getClasses().contains(c)) { return; } used.addClass(c); for (ImClassType superClass : c.getSuperClasses()) { - visitClass(superClass.getClassDef(), used, direct); + visitClass(superClass.getClassDef(), used); } } 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 cd162234f..f3aa73dc3 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 @@ -795,6 +795,31 @@ public void tupleSpecializedStaticInitializerRunsOnceWithoutErasedInstantiation( 2, countOccurrences(compiled, "bump()")); // one function declaration plus one call } + @Test + public void tupleSpecializedTypedLocalDoesNotRootErasedInitializer() throws IOException { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "class Box", + " static int value = bump()", + " construct()", + "init", + " let box = new Box()", + " if bumps == 1", + " testSuccess()" + ); + + String compiled = compiledLua( + "tupleSpecializedTypedLocalDoesNotRootErasedInitializer"); + assertEquals("a tuple-specialized local type must not retain the erased initializer", + 2, countOccurrences(compiled, "bump()")); // one function declaration plus one call + } + @Test public void tupleSpecializedStaticKeepsLiveErasedInitializer() { test().testLua(true).executeProg().lines( From f785dfa2a165e4fc0c0a8052cd216dbd20ccf2b7 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 13:40:31 +0200 Subject: [PATCH 09/12] Preserve generic instanceof identity in Lua --- .../imtranslation/EliminateGenerics.java | 77 ++++++++++++++++++- .../tests/LuaBackendAuditTests.java | 31 ++++++++ 2 files changed, 104 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 67725803f..2eb7a28b4 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 @@ -45,6 +45,8 @@ public class EliminateGenerics { private final Map functionOwners = new IdentityHashMap<>(); private final Table specializedMethods = HashBasedTable.create(); private final Table specializedClasses = HashBasedTable.create(); + /** Concrete generic identities named by runtime instanceof checks. */ + private final Table runtimeTypeSpecializations = HashBasedTable.create(); private final Multimap> onSpecializedClassTriggers = HashMultimap.create(); // Track concrete generic arguments for specialized functions to simplify later lookups @@ -111,9 +113,10 @@ public void transform() { } /** - * Lua normally erases generics. Generic construction and scalar storage for tuple type arguments - * are the operations which need the concrete type, so only specialize paths leading to those - * operations. All other generic calls and classes keep the Lua backend's erased representation. + * Lua normally erases generics. Generic construction and scalar storage for tuple type arguments, + * bounded dispatch, and parameterized runtime identity are the operations which need the concrete + * type, so only specialize paths leading to those operations. All other generic calls and classes + * keep the Lua backend's erased representation. */ public void transformGenericNewOnly() { transformGenericNewOnly(false); @@ -131,6 +134,9 @@ public void transformGenericNewOnly(boolean specializeTupleValueTypes) { // call on that result resolve. Repeat until a pass finds nothing new; collection is // idempotent, so this terminates once every reachable site has been rewritten. while (true) { + if (specializeTupleValueTypes) { + collectRuntimeTypeSpecializations(); + } collectGenericNewRoots(); if (genericsUses.isEmpty()) { break; @@ -297,11 +303,63 @@ public void visit(ImTypeIdOfClass typeId) { }); } + /** + * Records parameterized classes whose runtime identity is observed. Lua normally erases + * generics, but an instanceof target must denote the same concrete class as allocations of that + * instantiation; otherwise a tuple-specialized object is also an instance of every erased + * non-tuple instantiation. + */ + private void collectRuntimeTypeSpecializations() { + prog.accept(new Element.DefaultVisitor() { + @Override + public void visit(ImInstanceof instanceOf) { + super.visit(instanceOf); + ImClassType clazz = instanceOf.getClazz(); + if (!clazz.getTypeArguments().isEmpty() + && !typeArgumentsContainTypeVariable(clazz.getTypeArguments())) { + runtimeTypeSpecializations.put(clazz.getClassDef(), + new GenericTypes(clazz.getTypeArguments()), true); + } + } + }); + } + + private boolean needsRuntimeTypeSpecialization(ImClassType clazz) { + return !clazz.getTypeArguments().isEmpty() + && !typeArgumentsContainTypeVariable(clazz.getTypeArguments()) + && runtimeTypeSpecializations.contains(clazz.getClassDef(), + new GenericTypes(clazz.getTypeArguments())); + } + + private boolean needsRuntimeTypeSpecialization(ImClass clazz, + ImTypeArguments typeArguments) { + int classArgumentCount = clazz.getTypeVariables().size(); + if (classArgumentCount == 0 || typeArguments.size() < classArgumentCount) { + return false; + } + List classArguments = new ArrayList<>(classArgumentCount); + for (int i = 0; i < classArgumentCount; i++) { + ImTypeArgument argument = typeArguments.get(i); + if (containsTypeVariable(argument.getType())) { + return false; + } + classArguments.add(argument); + } + return runtimeTypeSpecializations.contains(clazz, new GenericTypes(classArguments)); + } + + private boolean needsRuntimeTypeSpecialization(ImFunctionCall call) { + ImClass owner = classOwning(call.getFunc()); + return owner != null + && needsRuntimeTypeSpecialization(owner, call.getTypeArguments()); + } + private void collectGenericNewUse(ImClassRelatedExprWithClass expression) { ImClassType clazz = expression.getClazz(); if (clazz.getTypeArguments().isEmpty() || typeArgumentsContainTypeVariable(clazz.getTypeArguments()) - || !shouldSpecializeTupleArguments(clazz.getTypeArguments())) { + || (!shouldSpecializeTupleArguments(clazz.getTypeArguments()) + && !needsRuntimeTypeSpecialization(clazz))) { return; } genericsUses.add(new GenericClazzUse(expression)); @@ -347,6 +405,7 @@ private void collectGenericNewUse(ImFunctionCall call) { } if (!call.getTypeArguments().isEmpty() && (shouldSpecializeTupleArguments(call.getTypeArguments()) + || needsRuntimeTypeSpecialization(call) || functionNeedsSpecialization(call.getFunc(), Collections.newSetFromMap(new IdentityHashMap<>())))) { if (!typeArgumentsContainTypeVariable(call.getTypeArguments())) { genericsUses.add(new GenericImFunctionCall(call)); @@ -452,6 +511,7 @@ private void collectGenericNewUse(ImAlloc alloc) { if (clazz.getTypeArguments().isEmpty() || typeArgumentsContainTypeVariable(clazz.getTypeArguments()) || (!shouldSpecializeTupleArguments(clazz.getTypeArguments()) + && !needsRuntimeTypeSpecialization(clazz) && !isConstructionOnlyInstantiation(clazz.getClassDef()))) { return; } @@ -664,6 +724,15 @@ public void visit(ImTypeVarDispatch dispatch) { found[0] = true; } + @Override + public void visit(ImInstanceof instanceOf) { + if (typeArgumentsContainTypeVariable(instanceOf.getClazz().getTypeArguments())) { + found[0] = true; + return; + } + super.visit(instanceOf); + } + @Override public void visit(ImAlloc alloc) { // Constructing a class whose methods dispatch has to be specialised as well: 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 f3aa73dc3..71288512c 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 @@ -451,6 +451,37 @@ public void tupleSpecializedClassPreservesRuntimeTypeOperations() throws IOExcep assertFalse(compiled.contains("tupleCopy")); } + @Test + public void tupleSpecializedClassPreservesGenericInstanceofIdentity() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "interface Marker", + "class Box implements Marker", + " T value", + " construct(T initial)", + " value = initial", + " function get() returns T", + " return value", + "function isIntBox(Marker value) returns bool", + " return value instanceof Box", + "init", + " Marker tupleBox = new Box(pair(6, 7))", + " Box intBox = new Box(1)", + " Marker intMarker = intBox", + " if tupleBox instanceof Box", + " and not (tupleBox instanceof Box)", + " and intMarker instanceof Box", + " and not (intMarker instanceof Box)", + " and intBox.get() == 1", + " and intBox.value == 1", + " and isIntBox(intMarker)", + " and not isIntBox(tupleBox)", + " testSuccess()" + ); + } + @Test public void tupleSpecializedClassRetainsNominalMetadataWhenItIsTheOnlyReachableForm() throws IOException { test().testLua(true).executeProg().lines( From 1ecd1979e56c3265456a66c4d1548292b99b6111 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 13:55:05 +0200 Subject: [PATCH 10/12] Preserve specialized identity in class hierarchies --- .../imtranslation/EliminateGenerics.java | 27 +++++++++++++++++-- .../tests/LuaBackendAuditTests.java | 6 +++++ 2 files changed, 31 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 2eb7a28b4..326c7c0f7 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 @@ -317,13 +317,33 @@ public void visit(ImInstanceof instanceOf) { ImClassType clazz = instanceOf.getClazz(); if (!clazz.getTypeArguments().isEmpty() && !typeArgumentsContainTypeVariable(clazz.getTypeArguments())) { - runtimeTypeSpecializations.put(clazz.getClassDef(), - new GenericTypes(clazz.getTypeArguments()), true); + GenericTypes generics = new GenericTypes(clazz.getTypeArguments()); + ImClass original = clazz.getClassDef(); + runtimeTypeSpecializations.put(original, generics, true); + ImClass existing = specializedClasses.get(original, generics); + if (existing != null) { + rewriteRuntimeTypeSuperEdges(original, generics, existing); + } } } }); } + /** Redirects concrete inheritance edges to the same class identity used by instanceof. */ + private void rewriteRuntimeTypeSuperEdges(ImClass original, GenericTypes generics, + ImClass specialized) { + for (ImClass clazz : prog.getClasses()) { + clazz.getSuperClasses().replaceAll(superType -> { + if (superType.getClassDef() != original + || typeArgumentsContainTypeVariable(superType.getTypeArguments()) + || !new GenericTypes(superType.getTypeArguments()).equals(generics)) { + return superType; + } + return JassIm.ImClassType(specialized, JassIm.ImTypeArguments()); + }); + } + } + private boolean needsRuntimeTypeSpecialization(ImClassType clazz) { return !clazz.getTypeArguments().isEmpty() && !typeArgumentsContainTypeVariable(clazz.getTypeArguments()) @@ -1873,6 +1893,9 @@ private ImClass specializeClass(ImClass c, GenericTypes generics) { List typeVars = c.getTypeVariables(); rewriteGenerics(newC, generics, typeVars); newC.getSuperClasses().replaceAll(this::specializeType); + if (runtimeTypeSpecializations.contains(c, generics)) { + rewriteRuntimeTypeSuperEdges(c, generics, newC); + } // NEW: Create specialized global variables for this class instantiation createSpecializedGlobals(c, generics, typeVars); 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 71288512c..bcc39cf64 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 @@ -466,10 +466,14 @@ public void tupleSpecializedClassPreservesGenericInstanceofIdentity() { " return value", "function isIntBox(Marker value) returns bool", " return value instanceof Box", + "class Child extends Box", + " construct(int initial)", + " super(initial)", "init", " Marker tupleBox = new Box(pair(6, 7))", " Box intBox = new Box(1)", " Marker intMarker = intBox", + " Marker child = new Child(2)", " if tupleBox instanceof Box", " and not (tupleBox instanceof Box)", " and intMarker instanceof Box", @@ -478,6 +482,8 @@ public void tupleSpecializedClassPreservesGenericInstanceofIdentity() { " and intBox.value == 1", " and isIntBox(intMarker)", " and not isIntBox(tupleBox)", + " and child instanceof Box", + " and not (child instanceof Box)", " testSuccess()" ); } From 77619f214eb5206ed914470b3ba8ea22ece57b89 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 14:21:42 +0200 Subject: [PATCH 11/12] Handle tuple varargs and generic identity inheritance --- .../imtranslation/EliminateGenerics.java | 56 +++++++++++++++---- .../imtranslation/EliminateTuples.java | 16 ++++++ .../imtranslation/ImTranslator.java | 5 ++ .../lua/translation/StmtTranslation.java | 12 ++-- .../tests/LuaBackendAuditTests.java | 12 ++++ .../tests/wurstscript/tests/VarargTests.java | 17 ++++++ 6 files changed, 103 insertions(+), 15 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 326c7c0f7..89563be53 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 @@ -47,6 +47,8 @@ public class EliminateGenerics { private final Table specializedClasses = HashBasedTable.create(); /** Concrete generic identities named by runtime instanceof checks. */ private final Table runtimeTypeSpecializations = HashBasedTable.create(); + private record RuntimeTypeUse(ImClass clazz, GenericTypes generics) { + } private final Multimap> onSpecializedClassTriggers = HashMultimap.create(); // Track concrete generic arguments for specialized functions to simplify later lookups @@ -319,16 +321,24 @@ public void visit(ImInstanceof instanceOf) { && !typeArgumentsContainTypeVariable(clazz.getTypeArguments())) { GenericTypes generics = new GenericTypes(clazz.getTypeArguments()); ImClass original = clazz.getClassDef(); - runtimeTypeSpecializations.put(original, generics, true); - ImClass existing = specializedClasses.get(original, generics); - if (existing != null) { - rewriteRuntimeTypeSuperEdges(original, generics, existing); + if (runtimeTypeSpecializations.put(original, generics, true) == null) { + rewriteExistingRuntimeTypeSuperEdges(); } } } }); } + private void rewriteExistingRuntimeTypeSuperEdges() { + for (Table.Cell cell + : new ArrayList<>(specializedClasses.cellSet())) { + if (needsRuntimeTypeSpecialization(cell.getRowKey(), cell.getColumnKey(), + new HashSet<>())) { + rewriteRuntimeTypeSuperEdges(cell.getRowKey(), cell.getColumnKey(), cell.getValue()); + } + } + } + /** Redirects concrete inheritance edges to the same class identity used by instanceof. */ private void rewriteRuntimeTypeSuperEdges(ImClass original, GenericTypes generics, ImClass specialized) { @@ -345,10 +355,35 @@ private void rewriteRuntimeTypeSuperEdges(ImClass original, GenericTypes generic } private boolean needsRuntimeTypeSpecialization(ImClassType clazz) { - return !clazz.getTypeArguments().isEmpty() - && !typeArgumentsContainTypeVariable(clazz.getTypeArguments()) - && runtimeTypeSpecializations.contains(clazz.getClassDef(), - new GenericTypes(clazz.getTypeArguments())); + if (typeArgumentsContainTypeVariable(clazz.getTypeArguments())) { + return false; + } + return needsRuntimeTypeSpecialization(clazz.getClassDef(), + new GenericTypes(clazz.getTypeArguments()), + new HashSet<>()); + } + + private boolean needsRuntimeTypeSpecialization(ImClass clazz, GenericTypes generics, + Set visited) { + if (!visited.add(new RuntimeTypeUse(clazz, generics))) { + return false; + } + if (runtimeTypeSpecializations.contains(clazz, generics)) { + return true; + } + if (generics.getTypeArguments().size() != clazz.getTypeVariables().size()) { + return false; + } + for (ImClassType superType : clazz.getSuperClasses()) { + ImClassType concreteSuper = (ImClassType) transformType(superType, generics, + clazz.getTypeVariables()); + if (!typeArgumentsContainTypeVariable(concreteSuper.getTypeArguments()) + && needsRuntimeTypeSpecialization(concreteSuper.getClassDef(), + new GenericTypes(concreteSuper.getTypeArguments()), visited)) { + return true; + } + } + return false; } private boolean needsRuntimeTypeSpecialization(ImClass clazz, @@ -365,7 +400,8 @@ private boolean needsRuntimeTypeSpecialization(ImClass clazz, } classArguments.add(argument); } - return runtimeTypeSpecializations.contains(clazz, new GenericTypes(classArguments)); + return needsRuntimeTypeSpecialization(clazz, new GenericTypes(classArguments), + new HashSet<>()); } private boolean needsRuntimeTypeSpecialization(ImFunctionCall call) { @@ -1893,7 +1929,7 @@ private ImClass specializeClass(ImClass c, GenericTypes generics) { List typeVars = c.getTypeVariables(); rewriteGenerics(newC, generics, typeVars); newC.getSuperClasses().replaceAll(this::specializeType); - if (runtimeTypeSpecializations.contains(c, generics)) { + if (needsRuntimeTypeSpecialization(c, generics, new HashSet<>())) { rewriteRuntimeTypeSuperEdges(c, generics, newC); } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java index c15e50a48..5e2c8f5eb 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateTuples.java @@ -154,11 +154,27 @@ private static void unionMethods(ImMethod left, ImMethod right, Map getTupleScalarVars(ImVar v) { + return getVarsForTuple(v).allValuesStream().toList(); + } + VarsForTupleResult getVarsForTuple(ImVar v, ImType concreteStorageType) { if (!TypesHelper.typeContainsTuples(v.getType()) && TypesHelper.typeContainsTuples(concreteStorageType)) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/StmtTranslation.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/StmtTranslation.java index 6afb1d8e7..cd722af06 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/StmtTranslation.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/StmtTranslation.java @@ -80,7 +80,7 @@ public static void translate(ImSet s, List res, LuaTranslator tr) public static void translate(ImVarargLoop loop, List res, LuaTranslator tr) { - LuaVariable loopVar = tr.luaVar.getFor(loop.getLoopVar()); + List loopVars = tr.imTr.getTupleScalarVars(loop.getLoopVar()); // The loop is built from real AST nodes (a while loop) instead of literal // 'for ... do' / 'end' lines: the printer stops printing a statement list // after a return/break (Lua forbids trailing statements), which would @@ -90,10 +90,12 @@ public static void translate(ImVarargLoop loop, List res, LuaTrans res.add(args); res.add(i); LuaStatements body = LuaAst.LuaStatements(); - body.add(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(i), - LuaAst.LuaExprBinary(LuaAst.LuaExprVarAccess(i), LuaAst.LuaOpPlus(), LuaAst.LuaExprIntVal("1")))); - body.add(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(loopVar), - LuaAst.LuaExprArrayAccess(LuaAst.LuaExprVarAccess(args), LuaAst.LuaExprlist(LuaAst.LuaExprVarAccess(i))))); + for (ImVar loopVar : loopVars) { + body.add(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(i), + LuaAst.LuaExprBinary(LuaAst.LuaExprVarAccess(i), LuaAst.LuaOpPlus(), LuaAst.LuaExprIntVal("1")))); + body.add(LuaAst.LuaAssignment(LuaAst.LuaExprVarAccess(tr.luaVar.getFor(loopVar)), + LuaAst.LuaExprArrayAccess(LuaAst.LuaExprVarAccess(args), LuaAst.LuaExprlist(LuaAst.LuaExprVarAccess(i))))); + } tr.translateStatements(body, loop.getBody()); res.add(LuaAst.LuaWhile( LuaAst.LuaExprBinary(LuaAst.LuaExprVarAccess(i), LuaAst.LuaOpLess(), 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 bcc39cf64..02a618bbd 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 @@ -469,11 +469,19 @@ public void tupleSpecializedClassPreservesGenericInstanceofIdentity() { "class Child extends Box", " construct(int initial)", " super(initial)", + "class GenericChild extends Box", + " construct(U initial)", + " super(initial)", + "class GrandChild extends GenericChild", + " construct(int initial)", + " super(initial)", "init", " Marker tupleBox = new Box(pair(6, 7))", " Box intBox = new Box(1)", " Marker intMarker = intBox", " Marker child = new Child(2)", + " Marker genericChild = new GenericChild(3)", + " Marker grandChild = new GrandChild(4)", " if tupleBox instanceof Box", " and not (tupleBox instanceof Box)", " and intMarker instanceof Box", @@ -484,6 +492,10 @@ public void tupleSpecializedClassPreservesGenericInstanceofIdentity() { " and not isIntBox(tupleBox)", " and child instanceof Box", " and not (child instanceof Box)", + " and genericChild instanceof Box", + " and not (genericChild instanceof Box)", + " and grandChild instanceof Box", + " and not (grandChild instanceof Box)", " testSuccess()" ); } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/VarargTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/VarargTests.java index 3bb01c67f..1d5861e49 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/VarargTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/VarargTests.java @@ -151,6 +151,23 @@ public void varargAllowsMoreThan31ArgumentsInLua() { ); } + @Test + public void tupleVarargPreservesElementGroupingInLua() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "function sumPairs(vararg pair pairs) returns int", + " var result = 0", + " for p in pairs", + " result = result * 100 + p.x * 10 + p.y", + " return result", + "init", + " if sumPairs(pair(1, 2), pair(3, 4), pair(5, 6)) == 123456", + " testSuccess()" + ); + } + @Test public void varargReceiverCountsAsJassParameter() { testAssertErrorsLines(false, "would generate 32 Jass parameters; the maximum is 31", From 75065aa39ea8b3266e2712add9b4fd68f5d32a8d Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 29 Aug 2026 14:38:47 +0200 Subject: [PATCH 12/12] Separate metadata from dispatch reachability --- .../lua/translation/RemoveGarbage.java | 61 +++++++++++-------- .../tests/LuaBackendAuditTests.java | 24 ++++++++ 2 files changed, 60 insertions(+), 25 deletions(-) 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 695cc2c09..f2951044e 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 @@ -31,6 +31,8 @@ private static class Used { // methods that will be added once the class is used: private final Multimap waitingMethods = HashMultimap.create(); private final Set classes = new HashSet<>(); + /** Classes whose reachable runtime objects may dispatch virtual methods. */ + private final Set dispatchClasses = new HashSet<>(); /** Classes which reachable code actually allocates, excluding nominal type-only references. */ private final Set instantiatedClasses = new HashSet<>(); private final Set vars = new HashSet<>(); @@ -47,7 +49,7 @@ public void addMethod(ImMethod m) { public void maybeVisitMethod(ImMethod m) { ImClass c = m.attrClass(); - if (classes.contains(c)) { + if (dispatchClasses.contains(c)) { visitMethod(m, this); } else { waitingMethods.put(c, m); @@ -82,22 +84,28 @@ public void addVar(ImVar var) { vars.add(var); } - public void addClass(ImClass c) { - classes.add(c); - ImClass nominalClass = translator.canonical(c); - if (nominalClass != c) { - // A targeted specialization has a distinct storage layout but keeps the source - // class's nominal type id and instanceof identity. The canonical class is therefore - // a real metadata dependency even when no source expression names it directly. - visitClass(nominalClass, this); - } - Collection imMethods = waitingMethods.get(c); - Iterator it = imMethods.iterator(); - while (it.hasNext()) { - ImMethod m = it.next(); - visitMethod(m, this); - it.remove(); + public boolean addClass(ImClass c, boolean dispatchReachable) { + boolean newClass = classes.add(c); + boolean newDispatchClass = dispatchReachable && dispatchClasses.add(c); + if (newClass) { + ImClass nominalClass = translator.canonical(c); + if (nominalClass != c) { + // A targeted specialization has a distinct storage layout but keeps the source + // class's nominal type id and instanceof identity. The canonical class is therefore + // a metadata dependency, not evidence that erased instances can dispatch. + visitClass(nominalClass, this, false); + } } + if (newDispatchClass) { + Collection imMethods = waitingMethods.get(c); + Iterator it = imMethods.iterator(); + while (it.hasNext()) { + ImMethod m = it.next(); + visitMethod(m, this); + it.remove(); + } + } + return newClass || newDispatchClass; } public void addInstantiatedClass(ImClass c) { @@ -264,25 +272,25 @@ public void visit(ImAlloc e) { @Override public void visit(ImDealloc e) { super.visit(e); - visitClass(e.getClazz().getClassDef(), used); + visitClass(e.getClazz().getClassDef(), used, false); } @Override public void visit(ImInstanceof e) { super.visit(e); - visitClass(e.getClazz().getClassDef(), used); + visitClass(e.getClazz().getClassDef(), used, false); } @Override public void visit(ImTypeIdOfObj e) { super.visit(e); - visitClass(e.getClazz().getClassDef(), used); + visitClass(e.getClazz().getClassDef(), used, false); } @Override public void visit(ImTypeIdOfClass e) { super.visit(e); - visitClass(e.getClazz().getClassDef(), used); + visitClass(e.getClazz().getClassDef(), used, false); } @Override @@ -304,7 +312,7 @@ private static void visitMethod(ImMethod m, Used used) { return; } used.addMethod(m); - visitClass(m.getMethodClass().getClassDef(), used); + visitClass(m.getMethodClass().getClassDef(), used, false); if (m.getImplementation() != null) { // abstract methods can have no implementation visitFunction(m.getImplementation(), used); @@ -315,12 +323,15 @@ private static void visitMethod(ImMethod m, Used used) { } private static void visitClass(ImClass c, Used used) { - if (used.getClasses().contains(c)) { + visitClass(c, used, true); + } + + private static void visitClass(ImClass c, Used used, boolean dispatchReachable) { + if (!used.addClass(c, dispatchReachable)) { return; } - used.addClass(c); for (ImClassType superClass : c.getSuperClasses()) { - visitClass(superClass.getClassDef(), used); + visitClass(superClass.getClassDef(), used, dispatchReachable); } } @@ -361,7 +372,7 @@ public void case_ImArrayTypeMulti(ImArrayTypeMulti tt) { @Override public void case_ImClassType(ImClassType tt) { - visitClass(tt.getClassDef(), used); + visitClass(tt.getClassDef(), used, false); for (ImTypeArgument ta : tt.getTypeArguments()) { visitType(ta.getType(), used); } 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 02a618bbd..7f5d1fef1 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 @@ -910,6 +910,30 @@ public void tupleSpecializedStaticKeepsInitializerForConstructedErasedClass() { ); } + @Test + public void tupleSpecializedInterfaceDispatchDoesNotRootErasedStaticInitializer() { + test().testLua(true).executeProg().lines( + "package Test", + "native testSuccess()", + "tuple pair(int x, int y)", + "int bumps", + "function bump() returns int", + " bumps++", + " return bumps", + "interface Reader", + " function read() returns int", + "class Box implements Reader", + " static int value = bump()", + " construct()", + " function read() returns int", + " return value", + "init", + " Reader reader = new Box()", + " if reader.read() == 1 and bumps == 1", + " testSuccess()" + ); + } + @Test public void tupleSpecializedStaticInitializerCycleDoesNotRootErasedCopy() throws IOException { test().testLua(true).executeProg().lines(