diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaHashtable.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaHashtable.java index 7333fa66d..eb09da0ca 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaHashtable.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaHashtable.java @@ -153,6 +153,15 @@ public static String stubName(String nativeName) { return OPS.get(f.getName()); } + /** + * Whether {@code f} is the string load: the common.j native before {@link LuaNativeLowering#transform} replaces + * its calls, or the stub after. It answers the null string, "", when nothing is stored. + */ + public static boolean isStringLoad(ImTranslator translator, ImFunction f) { + Op op = f.isBj() ? (isNative(f.getName()) ? opOfName(f.getName()) : null) : op(translator, f); + return op != null && op.kind() == Kind.LOAD && op.values() == Values.STR; + } + /** Whether {@code f} is the stub of a load or a HaveSaved test, which only read the hashtable. */ public static boolean isRead(ImTranslator translator, ImFunction f) { Op op = op(translator, f); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java index d13eaf838..2857294c5 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/LuaNativeLowering.java @@ -383,6 +383,7 @@ private static ImExpr concatenation(ImOperatorCall call, ImTranslator translator * Natives whose result is a string whatever the runtime does. Only {@code I2S}: the backend prints * it as {@code tostring}, which cannot answer nil. A native which merely returns a string in the * game, such as {@code R2S}, is not trusted to: a runtime of one's own may declare it to answer nil. + * The emulated string load is matched by identity ({@link LuaHashtable#isStringLoad}). */ private static final Set STRING_NATIVES_NEVER_NIL = Set.of("I2S"); @@ -401,7 +402,8 @@ public static boolean neverNil(ImExpr e, ImTranslator translator) { return f == translator.luaRawConcatFunc || f == translator.luaRawOrEmptyFunc || f == translator.luaRawToStringFunc - || (f.isNative() && STRING_NATIVES_NEVER_NIL.contains(f.getName())); + || (f.isNative() && STRING_NATIVES_NEVER_NIL.contains(f.getName())) + || LuaHashtable.isStringLoad(translator, f); } return false; } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaHashtableTranslation.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaHashtableTranslation.java index aa5207ecb..605de60db 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaHashtableTranslation.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaHashtableTranslation.java @@ -70,7 +70,8 @@ static LuaExpr expression(ImFunctionCall call, LuaHashtable.Op op, LuaTranslator case INT -> LuaAst.LuaExprBinary(value, LuaAst.LuaOpOr(), LuaAst.LuaExprIntVal("0")); case REAL -> LuaAst.LuaExprBinary(value, LuaAst.LuaOpOr(), LuaAst.LuaExprRealVal("0.0")); case BOOL -> LuaAst.LuaExprBinary(value, LuaAst.LuaOpEquals(), LuaAst.LuaExprBoolVal(true)); - case STR -> value; + // Lua's null string is "", which is what the game's Lua natives answer where Jass answers null + case STR -> LuaAst.LuaExprBinary(value, LuaAst.LuaOpOr(), LuaAst.LuaExprStringVal("")); case HANDLE -> { LuaExpr accepted = LuaAst.LuaExprArrayAccess(LuaAst.LuaExprVarAccess(tr.hashtableAccepts(op.handleType())), LuaAst.LuaExprlist(read(copies(operands), LuaHashtable.Values.HANDLE_TYPE, tr))); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaNatives.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaNatives.java index 181a9c6e8..72103336a 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaNatives.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaNatives.java @@ -390,8 +390,8 @@ public class LuaNatives { // true only for a stored true, as LoadBoolean answers false when nothing is stored addHashtableLoad(withWurstPrefix("LoadBoolean"), "__wurst_ht_bool", "false", "x[c] == true"); addHashtableLoad(withWurstPrefix("LoadReal"), "__wurst_ht_real", "0.0", "x[c] or 0.0"); - // nil when absent, as the string loads have always answered - addHashtableLoad(withWurstPrefix("LoadStr"), "__wurst_ht_str", "nil", "x[c]"); + // the null string when absent, which on Lua is "": the game's natives raise on nil + addHashtableLoad(withWurstPrefix("LoadStr"), "__wurst_ht_str", "\"\"", "x[c] or \"\""); for (String name : HASHTABLE_HANDLE_LOAD_NAMES) { String type = handleTypeOf(name); StringBuilder accepted = new StringBuilder(); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaHashtableTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaHashtableTests.java index 24a134900..eb0984f36 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaHashtableTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaHashtableTests.java @@ -102,8 +102,8 @@ public void hashtableOperationsArePrintedWhereTheyAreCalled() throws IOException assertMatches("a handle load answers the handle only for a tag it accepts", "\\(\\(__wurst_htAccepts_Timer\\[\\(\\(\\w+ and \\w+\\.__wurst_ht_htype\\[1\\]\\) or __wurst_htEmpty\\)\\[3\\]\\] and " + "\\(\\(\\w+ and \\w+\\.__wurst_ht_handle\\[1\\]\\) or __wurst_htEmpty\\)\\[3\\]\\) or nil\\)", init); - assertMatches("a string load is nil when absent, which the concatenation guards", - "\\(\\(\\(\\w+ and \\w+\\.__wurst_ht_str\\[1\\]\\) or __wurst_htEmpty\\)\\[2\\] or \"\"\\)", init); + assertMatches("a string load is the null string, \"\", when absent, which the concatenation need not guard", + "\"x\" \\.\\. \\(\\(\\(\\w+ and \\w+\\.__wurst_ht_str\\[1\\]\\) or __wurst_htEmpty\\)\\[2\\] or \"\"\\)\\)", init); assertMatches("a remove never writes the shared empty child", "if \\((\\w+) and \\1\\.__wurst_ht_int\\[1\\]\\) then\\s+\\1\\.__wurst_ht_int\\[1\\]\\[2\\] = nil\\s+end", init); for (String subtable : new String[]{"int", "bool", "real", "str", "handle", "htype"}) { @@ -136,6 +136,9 @@ public void hashtableOperationsArePrintedWhereTheyAreCalled() throws IOException " SaveInteger(h, 1, 1, 11)", " check(LoadInteger(h, 1, 1) == 11 and HaveSavedInteger(h, 1, 1), \"int\")", " check(LoadInteger(h, 1, 2) == 0 and not HaveSavedInteger(h, 1, 2), \"an absent child key reads 0\")", + // Lua's null string is "", which is what the game's Lua natives answer where Jass answers null; they raise on nil + " check(StringLength(LoadStr(h, 1, 1)) == 0 and LoadStr(h, 1, 1) == null and StringLength(LoadStr(h, 1, 2)) == 0," + + " \"an absent string is the null string\")", " check(LoadReal(h, 1, 1) == 0. and not HaveSavedReal(h, 1, 1), \"each kind has its own entries\")", " SaveBoolean(h, 1, 1, false)", " check(HaveSavedBoolean(h, 1, 1) and not LoadBoolean(h, 1, 1), \"a stored false\")", @@ -181,6 +184,7 @@ public void hashtableOperationsArePrintedWhereTheyAreCalled() throws IOException " SaveTimerHandle(h, 1, 1, t)", " check(LoadInteger(h, 1, 1) == 0 and not HaveSavedInteger(h, 1, 1) and LoadTimerHandle(h, 1, 1) == null," + " \"a flushed hashtable keeps nothing\")", + " check(StringLength(LoadStr(h, 1, 1)) == 0 and LoadStr(h, 1, 1) == null, \"a flushed hashtable reads the null string\")", " FlushChildHashtable(h, 1)", " RemoveSavedInteger(h, 1, 1)", " FlushParentHashtable(h)", @@ -194,6 +198,7 @@ public void hashtableOperationsArePrintedWhereTheyAreCalled() throws IOException " check(LoadInteger(none, 1, 1) == 0 and LoadReal(none, 1, 1) == 0. and not LoadBoolean(none, 1, 1)" + " and \"x\" + LoadStr(none, 1, 1) == \"x\" and LoadTimerHandle(none, 1, 1) == null" + " and not HaveSavedInteger(none, 1, 1), \"a null hashtable reads defaults\")", + " check(StringLength(LoadStr(none, 1, 1)) == 0 and LoadStr(none, 1, 1) == null, \"a null hashtable reads the null string\")", " check(LoadInteger(ct, 1, 2) == 12 and LoadStr(ct, 1, 3) == \"ct\" and LoadBoolean(ct, 4, 5)" + " and LoadReal(ct, 6, 7) == 1.5 and not HaveSavedInteger(ct, 1, 3), \"a compile-time hashtable\")", " testSuccess()" @@ -225,6 +230,8 @@ public void anOperandWithAnEffectKeepsTheCallOfTheHelper() throws IOException { "@noinline function key(string tag, int k) returns int", " trace += tag", " return k", + "function loadZ(hashtable h) returns string", + " return LoadStr(h, key(\"z\", 7), 1)", "init", " let h = InitHashtable()", " SaveInteger(h, key(\"p\", 1), key(\"c\", 2), key(\"v\", 3))", @@ -248,6 +255,11 @@ public void anOperandWithAnEffectKeepsTheCallOfTheHelper() throws IOException { " SaveInteger(none, key(\"n\", 1), 1, 1)", " if LoadInteger(none, key(\"l\", 1), 1) != 0 or HaveSavedInteger(none, key(\"h\", 1), 1)", " testFail(\"a null hashtable through the helper\")", + " if StringLength(LoadStr(h, key(\"s\", 7), 1)) != 0 or StringLength(LoadStr(none, key(\"t\", 1), 1)) != 0", + " testFail(\"an absent string through the helper\")", + // guarded before inlining, as the call of loadZ; inlined, the guard holds the stub, which answers "" + " if \"x\" + loadZ(h) != \"x\"", + " testFail(\"an absent string joined through the helper\")", " if HaveSavedInteger(h, 1, 2) or HaveSavedInteger(h, 1, 4) or LoadInteger(h, 9, 2) != 0", " testFail(\"removed and flushed\")", " testSuccess()"); @@ -256,12 +268,16 @@ public void anOperandWithAnEffectKeepsTheCallOfTheHelper() throws IOException { LuaTranslator.assertNoLeakedHashtableNativeCalls(compiled); String init = functionBody(compiled, "init_Test"); for (String helper : new String[]{"SaveInteger", "LoadInteger", "HaveSavedInteger", "RemoveSavedInteger", - "FlushChildHashtable"}) { + "FlushChildHashtable", "LoadStr"}) { assertTrue("an operation with an effect in an operand calls its helper: " + helper + "\n" + init, init.contains("__wurst_" + helper + "(")); assertTrue("a helper which is called is defined: " + helper + "\n" + compiled, compiled.contains("function __wurst_" + helper + "(")); } + int joined = init.indexOf("\"x\" .. __wurst_LoadStr("); + assertTrue("the string load joined through the helper:\n" + init, joined >= 0); + String join = init.substring(joined, init.indexOf('\n', joined)); + assertFalse("the helper answers the null string, so the join adds no guard: " + join, join.contains("or \"\"")); assertMatches("a save with plain operands is printed in place", ";\\((\\w+)\\.__wurst_ht_int\\[1\\] or __wurst_htNewChild\\(\\1\\.__wurst_ht_int, 1\\)\\)\\[6\\] = 7", init); assertMatches("a load with plain operands is printed in place", diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaNativesTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaNativesTests.java index 236f0839e..cca0f8d2a 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaNativesTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaNativesTests.java @@ -88,7 +88,9 @@ public void hashtableLoadsUseTypeSpecificBuckets() { assertTrue(loadStr.contains("h.__wurst_ht_str")); assertTrue(loadHandle.contains("h.__wurst_ht_handle")); assertTrue(loadInt, loadInt.contains("return x[c] or 0")); - assertTrue("a missing string is nil: " + loadStr, loadStr.contains("return nil") && loadStr.contains("return x[c]\n")); + assertTrue("a missing string is the null string, \"\": " + loadStr, + loadStr.contains("if h == nil then return \"\" end") && loadStr.contains("return x[c] or \"\"") + && !loadStr.contains("return nil")); assertTrue(loadBool, loadBool.contains("return x[c] == true")); assertFalse("no subtable is ever missing: " + loadInt, loadInt.contains("t == nil")); } diff --git a/docs/WC3_RUNTIME.md b/docs/WC3_RUNTIME.md index a48dbf8fd..706fe222b 100644 --- a/docs/WC3_RUNTIME.md +++ b/docs/WC3_RUNTIME.md @@ -25,6 +25,7 @@ A test that passes in the first two columns says nothing about the third where t * **code** Lua keeps one static table per field, indexed by id, and a class descriptor per live id. `destroy` removes the descriptor and recycles the id; field storage is not cleared, as Jass arrays are not. Until the id is reused a stale reference fails virtual dispatch and `instanceof` is false. Lua's collector therefore frees no Wurst object, and a table stored in a field stays reachable after `destroy`. * **measured, 3.0.0** Hashtables: a handle load is typed (a saved unit loads as itself through `LoadUnitHandle` and `LoadWidgetHandle`, as null through `LoadItemHandle` or `LoadTimerHandle`); saving a null handle stores nothing and keeps what was there; `FlushParentHashtable` destroys the hashtable, so nothing saved to it afterwards stays and every load answers its default; a null hashtable answers every load with its default and ignores every write. The Lua emulation (`LuaHashtable`) and the interpreter (`HashtableProvider`) answer the same. A handle saved through a supertype (`SaveAgentHandle`, `SaveWidgetHandle`) and loaded as a subtype is answered on Lua, which cannot tell its type. +* **measured, 3.0.0** Null strings: Jass keeps null and `""` apart (`null == ""` is false; an unset string array element, field or global is null; `SubString` past the end, any `SubString` of `""` and `StringCase("")` answer null; `StringLength`, `S2I`, `S2R` and `StringHash` of null answer 0, as of `""`). The game's Lua natives answer `""` in each of those places, and every string native probed raises on a nil argument ("string expected, got nil": `StringLength`, `SubString`, `StringCase`, `S2I`, `S2R`, `StringHash`, `GetLocalizedString`, `BlzSetUnitName`, `SetTextTagText`, `BlzFrameSetText`, `DisplayTextToPlayer`). So on Lua the null string is `""`, as the game's natives treat it (`EliminateLocalTypes`), and nothing the compiler emits answers nil for a string: the emulated `LoadStr` answers `""` when nothing is stored. `null == ""` is therefore true on Lua and false on Jass; matching Jass would mean wrapping every native which returns a string. In Jass, `BlzSetUnitName(u, null)` crashes the client. ## The game's Lua