From 4b96b0e8c1282f66a8f9493caedb1637ab6ae248 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 11 Oct 2026 13:28:57 +0200 Subject: [PATCH 1/3] Interpreters: a real division by zero stops, as it stops the thread in Jass Measured on the 3.0.0 client: 1. / 0., 0. / 0., 5. % 0. and ModuloReal(5., 0.) stop the thread in Jass. The interpreters answered an infinity and went on; a real division or modulo by zero is now the same error as an integer one. --- .../de/peeeq/wurstscript/WurstOperator.java | 15 +++++++++-- .../intermediatelang/ILconstInt.java | 4 ++- .../intermediatelang/ILconstReal.java | 6 +++-- .../tests/LuaBackendAuditTests.java | 26 +++++++++++++++++++ docs/WC3_RUNTIME.md | 2 ++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java index 87597ab48..4ef3cdc27 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java @@ -163,7 +163,7 @@ public ILconst evaluateBinaryOperator(ILconst left, case DIV_INT: return new ILconstInt(((ILconstInt) left).getVal() / ((ILconstInt) right.get()).getVal()); case DIV_REAL: - return new ILconstReal(getReal(left) / getReal(right.get())); + return new ILconstReal(divideReal(getReal(left), getReal(right.get()))); case EQ: { ILconst rightVal = right.get(); rejectUnresolvedDefault(left, rightVal); @@ -229,7 +229,7 @@ public static int jassModuloInteger(int a, int b) { * probed operand; math.fmod and Lua's float % do not (360 % 7 is 3.0000153 with either). */ public static float moduloReal(float a, float b) { - float quotient = a / b; + float quotient = divideReal(a, b); float r = a - (float) realToInt(quotient) * b; if (r < 0) { r += b; @@ -237,6 +237,17 @@ public static float moduloReal(float a, float b) { return r; } + /** + * A real division. Measured on the 3.0.0 client, a real division by zero stops the thread in Jass ({@code 1. / 0.}, + * {@code 0. / 0.} and ModuloReal by 0 alike), so it is the same error as an integer one here, not an infinity. + */ + public static float divideReal(float a, float b) { + if (b == 0) { + throw new ArithmeticException("/ by zero"); + } + return a / b; + } + /** Jass's R2I, measured on the 3.0.0 client: truncates, and wraps to 32 bits rather than saturating (1e10 is 1410065408). */ public static int realToInt(double value) { // Truncated in double and wrapped by remainder: a cast to long would saturate above its range. diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstInt.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstInt.java index a8ebf00a6..8f9bdc042 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstInt.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstInt.java @@ -1,5 +1,7 @@ package de.peeeq.wurstscript.intermediatelang; +import de.peeeq.wurstscript.WurstOperator; + import de.peeeq.wurstscript.types.WurstType; import de.peeeq.wurstscript.types.WurstTypeInt; @@ -71,7 +73,7 @@ public ILconstNum div(ILconstNum other) { if (other instanceof ILconstInt iLconstInt) { return create(val / iLconstInt.getVal()); } else if (other instanceof ILconstReal iLconstReal) { - return ILconstReal.create(val / iLconstReal.getVal()); + return ILconstReal.create(WurstOperator.divideReal(val, iLconstReal.getVal())); } else { throw new Error(); } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstReal.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstReal.java index c131c0acb..f39d65004 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstReal.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstReal.java @@ -1,5 +1,7 @@ package de.peeeq.wurstscript.intermediatelang; +import de.peeeq.wurstscript.WurstOperator; + import de.peeeq.wurstscript.types.WurstType; import de.peeeq.wurstscript.types.WurstTypeReal; @@ -74,9 +76,9 @@ public ILconstNum mul(ILconstNum other) { @Override public ILconstNum div(ILconstNum other) { if (other instanceof ILconstInt iLconstInt) { - return create(val / iLconstInt.getVal()); + return create(WurstOperator.divideReal(val, iLconstInt.getVal())); } else if (other instanceof ILconstReal iLconstReal) { - return create(val / iLconstReal.getVal()); + return create(WurstOperator.divideReal(val, iLconstReal.getVal())); } else { throw new Error(); } 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 0699f8af3..29735f3af 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 @@ -7604,6 +7604,32 @@ public void modAgreesWithJassForEveryDivisorSignWhenFolded() { * formula and 1 by fmod. The comparisons are exact ({@code !=}), as Jass's {@code ==} on reals has a * tolerance. */ + /** + * Measured on the 3.0.0 client, a real division or modulo by zero stops the thread in Jass ({@code 1. / 0.}, + * {@code 0. / 0.}, {@code 5. % 0.} and ModuloReal by 0 alike). The interpreters, which model Jass, stop too, as + * for an integer division by zero, rather than going on with an infinity. + */ + @Test + public void realDivisionByZeroStopsTheInterpreters() { + for (String expr : new String[]{"w(1.) / w(0.)", "w(5.) % w(0.)", "w(0.) / w(0.)"}) { + Throwable stopped = null; + try { + test().executeProg().lines( + "package Test", + "native testSuccess()", + "@noinline function w(real x) returns real", + " return x", + "init", + " let x = " + expr, + " if x == x or x != x", + " testSuccess()"); + } catch (Throwable t) { + stopped = t; + } + assertTrue("a real division by zero stops: " + expr, stopped != null && String.valueOf(stopped.getMessage()).contains("by zero")); + } + } + @Test public void realModIsModuloRealsFormulaInEveryRun() { test().testLua(true).luaOnly(false).withStdLib().executeProg().lines( diff --git a/docs/WC3_RUNTIME.md b/docs/WC3_RUNTIME.md index edb216f9b..37695e259 100644 --- a/docs/WC3_RUNTIME.md +++ b/docs/WC3_RUNTIME.md @@ -47,6 +47,8 @@ Every client simulates the same game from the same inputs; anything that makes t * **measured, 3.0.0** `R2I` and the arithmetic `x // 1 | 0` agree on every value probed, the 32-bit edges and values outside the range included (`LuaNativeLowering.lowerRealToInt`). * **measured, 3.0.0** A real modulo is Blizzard.j's `ModuloReal` on Jass, `a - I2R(R2I(a / b)) * b` plus `b` when negative. The same formula in the game's Lua gives Jass's result for every dividend both read alike (54 of 54 probed); Lua's float `%` and `math.fmod` do not: `360. % 7.` is 3.0000153 with either and 3 in Jass, `7.5 % 360.` is 7.4999995 and 7.5, and `-7.5 % 2.5` is 2.4999993 with fmod and its correction and 0 in Jass. The integer `%` agrees with Jass's `ModuloInteger` for a positive divisor on every value probed, the 32-bit edges included. So a real modulo is that formula with `R2I` as Lua arithmetic, and only an integer one by a positive literal is `%` (`LuaNativeLowering`). +* **measured, 3.0.0** A real division by zero stops the thread in Jass: `1. / 0.`, `-1. / 0.`, `0. / 0.`, `5. % 0.` and `ModuloReal(5., 0.)` all do. The game's Lua goes on: `1. / 0.` is 1.7014e38 (the largest float, not an infinity), `-1. / 0.` its negative, `0. / 0.` is 1, and the Lua real modulo by 0 answers a number. The interpreters stop as Jass does (`WurstOperator.divideReal`); the Lua backend adds no check, which would cost every real division. + ## Establishing a fact * Measure in the real client with discriminating values, such as `2^24 + 1` or sums just below an integer, computed at run time from array operands so that nothing is folded, and read the emitted Lua to confirm. State the client version where the fact is recorded. From dc729dd7cf2f9ac7f374f8f23a77a253f99ad11f Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 11 Oct 2026 16:48:27 +0200 Subject: [PATCH 2/3] R2I saturates and R2S prints the whole number, as measured on computed values Measured on the 3.0.0 client with operands computed at run time: R2I saturates outside the 32-bit range in Jass and in the game's Lua, and R2S and R2SW print the whole integer part. The wrap the interpreter modelled was Jass's reading of real literals past 2^31. The interpreter's R2I saturates and its R2S and R2SW print the whole number; on Lua the R2I lowering answers the bound itself instead of calling the global R2I, which the test runtime did not define. --- .../providers/StringProvider.java | 43 +++++++++++-------- .../de/peeeq/wurstscript/WurstOperator.java | 10 +++-- .../lua/translation/ExprTranslation.java | 14 ++++-- .../lua/translation/LuaNatives.java | 8 +++- .../wurstscript/tests/InterpreterTests.java | 24 ++++++----- .../tests/LuaBackendAuditTests.java | 18 ++++++++ docs/WC3_RUNTIME.md | 4 +- 7 files changed, 82 insertions(+), 39 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/jassinterpreter/providers/StringProvider.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/jassinterpreter/providers/StringProvider.java index 18e4b64bd..ed44272e8 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/jassinterpreter/providers/StringProvider.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/jassinterpreter/providers/StringProvider.java @@ -68,14 +68,14 @@ public ILconstReal S2R(@Nullable ILconstString s) { return new ILconstReal(Float.isInfinite(value) ? 0 : value); } - /** Measured on the 3.0.0 client: three decimals, rounded half up. The integer part wraps to 32 bits - * (3e9 prints -1294967296.000), and a negative value which rounds to zero keeps its sign. */ + /** Measured on the 3.0.0 client: three decimals, rounded half up, the whole integer part (a computed 3e9 prints + * 3000000000.000, 5000000320 prints 5000000320.000), and a negative value which rounds to zero keeps its sign. + * A real literal past 2^31 is read wrapped by Jass (the literal 3000000000. is negative), which R2S shows. */ public ILconstString R2S(ILconstReal r) { float x = r.getVal(); - long[] parts = roundedParts(Math.abs(x), 3); - int whole = (int) (x < 0 ? -parts[0] : parts[0]); - String sign = x < 0 && whole == 0 ? "-" : ""; - return ILconstString.fromText(sign + whole + "." + StringUtils.leftPad(Long.toString(parts[1]), 3, '0')); + Rounded parts = roundedParts(Math.abs(x), 3); + String sign = x < 0 ? "-" : ""; + return ILconstString.fromText(sign + parts.whole() + "." + StringUtils.leftPad(Long.toString(parts.digits()), 3, '0')); } /** Measured on the 3.0.0 client: the sign comes first, then the integer part left-padded to @@ -91,37 +91,42 @@ public ILconstString R2SW(ILconstReal r, ILconstInt width, ILconstInt precision) + ", beyond what the interpreter models (precision up to " + R2SW_MAX_PRECISION + ", width up to " + R2SW_MAX_WIDTH + ")."); } - long[] parts = roundedParts(Math.abs(x), digits); - // The signed value wraps to 32 bits, as in R2S; the sign is printed ahead of the padding. - int wrapped = (int) (x < 0 ? -parts[0] : parts[0]); - String sign = wrapped < 0 || (wrapped == 0 && x < 0) ? "-" : ""; - String whole = StringUtils.leftPad(Long.toString(Math.abs((long) wrapped)), (int) Math.max(0, padding)); - String fraction = digits == 0 ? "0" : StringUtils.leftPad(Long.toString(parts[1]), digits, '0'); + Rounded parts = roundedParts(Math.abs(x), digits); + // The whole integer part, as in R2S; the sign is printed ahead of the padding. + String sign = x < 0 ? "-" : ""; + String whole = StringUtils.leftPad(parts.whole().toString(), (int) Math.max(0, padding)); + String fraction = digits == 0 ? "0" : StringUtils.leftPad(Long.toString(parts.digits()), digits, '0'); return ILconstString.fromText(sign + whole + "." + fraction); } private static final int R2SW_MAX_PRECISION = 18; private static final int R2SW_MAX_WIDTH = 1000; + /** The whole part of a non-negative value, exact even past the long range, and its fraction digits. */ + private record Rounded(java.math.BigInteger whole, long digits) { + } + /** * The whole part and the fraction digits of a non-negative value, rounded half up at the given * precision and carried into the whole part. The fraction is scaled in float arithmetic, as the * engine does, which is what shows at nine digits: its R2SW(r, 1, 9) is not the exact decimal. */ - private static long[] roundedParts(float value, int precision) { - // Math.floor is exact for any float, and the integer part is wrapped to 32 bits by remainder - // rather than by a cast to long, which would saturate above Long.MAX_VALUE. Past 2^56 every - // float is a multiple of 2^32, so its wrapped part is 0. + private static Rounded roundedParts(float value, int precision) { + if (!Float.isFinite(value)) { + // not measured; the game's reals do not reach it through arithmetic (a division by zero stops the thread) + return new Rounded(java.math.BigInteger.ZERO, 0); + } + // Math.floor is exact for any float, and so is its decimal expansion. double floor = Math.floor(value); - long whole = (long) (floor % 4294967296.0); + java.math.BigInteger whole = new java.math.BigDecimal(floor).toBigInteger(); float fraction = (float) (value - floor); long limit = (long) Math.pow(10, precision); long digits = (long) (fraction * (float) limit + 0.5f); if (digits >= limit) { - whole++; + whole = whole.add(java.math.BigInteger.ONE); digits -= limit; } - return new long[] {whole, digits}; + return new Rounded(whole, digits); } /** See {@link WurstOperator#realToInt}, measured on the 3.0.0 client. */ diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java index 4ef3cdc27..03bbebcdd 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java @@ -248,11 +248,13 @@ public static float divideReal(float a, float b) { return a / b; } - /** Jass's R2I, measured on the 3.0.0 client: truncates, and wraps to 32 bits rather than saturating (1e10 is 1410065408). */ + /** + * Jass's R2I, measured on the 3.0.0 client: truncates, and saturates outside the 32-bit range (a computed 5000000320 + * is 2147483647, its negative -2147483648, a computed 1e20 2147483647), as Java's cast does. A real literal past + * 2^31 is read wrapped by Jass (the literal 1e10 is 1410065408.), which R2I of it shows, not R2I itself. + */ public static int realToInt(double value) { - // Truncated in double and wrapped by remainder: a cast to long would saturate above its range. - double truncated = value < 0 ? Math.ceil(value) : Math.floor(value); - return (int) (long) (truncated % 4294967296.0); + return (int) value; } /** diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/ExprTranslation.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/ExprTranslation.java index e2b2caca1..28345fe76 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/ExprTranslation.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/ExprTranslation.java @@ -251,7 +251,8 @@ static LuaExpr keyedMapRead(String stub, LuaExpr table, LuaExpr key) { * float, and {@code | 0} turns it into an integer, which it can because the range was checked; a * negative real is truncated as the negated floor of its negation. Both middle values are integers, * which Lua counts as true even when they are 0, so {@code and}/{@code or} pick the right branch. - * NaN, the infinities and everything outside the range call the native. + * Outside the range R2I saturates, in Jass and in the game's Lua alike (measured on the 3.0.0 client: a computed + * 5000000320 is 2147483647), so the bound is answered in place, and NaN is 0 as in the interpreter. */ static LuaExpr realToInt(LuaExpr x) { LuaExpr nonNegative = LuaAst.LuaExprBinary( @@ -265,13 +266,20 @@ static LuaExpr realToInt(LuaExpr x) { LuaExpr truncated = floorToInt(x.copy()); LuaExpr negatedTruncated = LuaAst.LuaExprUnary(LuaAst.LuaOpMinus(), floorToInt(LuaAst.LuaExprUnary(LuaAst.LuaOpMinus(), x.copy()))); - LuaExpr nativeCall = LuaAst.LuaExprFunctionCallByName("R2I", LuaAst.LuaExprlist(x.copy())); + LuaExpr saturated = LuaAst.LuaExprBinary( + LuaAst.LuaExprBinary(LuaAst.LuaExprBinary(x.copy(), LuaAst.LuaOpGreater(), LuaAst.LuaExprRealVal("0.0")), + LuaAst.LuaOpAnd(), LuaAst.LuaExprIntVal("2147483647")), + LuaAst.LuaOpOr(), + LuaAst.LuaExprBinary( + LuaAst.LuaExprBinary(LuaAst.LuaExprBinary(x.copy(), LuaAst.LuaOpLess(), LuaAst.LuaExprRealVal("0.0")), + LuaAst.LuaOpAnd(), LuaAst.LuaExprIntVal("-2147483648")), + LuaAst.LuaOpOr(), LuaAst.LuaExprIntVal("0"))); return LuaAst.LuaExprBinary( LuaAst.LuaExprBinary(nonNegative, LuaAst.LuaOpAnd(), truncated), LuaAst.LuaOpOr(), LuaAst.LuaExprBinary( LuaAst.LuaExprBinary(negative, LuaAst.LuaOpAnd(), negatedTruncated), - LuaAst.LuaOpOr(), nativeCall)); + LuaAst.LuaOpOr(), saturated)); } private static LuaExpr floorToInt(LuaExpr x) { 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 7a793649e..34238d0d4 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 @@ -132,6 +132,9 @@ public class LuaNatives { // Truncate toward zero. Not math.modf: that returns TWO values // (which expand into enclosing argument lists) and its integral // part is a float (tostring would yield "3.0" instead of "3"). + // Saturates outside the 32-bit range, as the game's R2I does (measured on the 3.0.0 client). + f.getBody().add(LuaAst.LuaLiteral("if x >= 2147483647 then return 2147483647 end")); + f.getBody().add(LuaAst.LuaLiteral("if x <= -2147483648 then return -2147483648 end")); f.getBody().add(LuaAst.LuaLiteral("if x >= 0 then")); f.getBody().add(LuaAst.LuaLiteral(" return math.floor(x)")); f.getBody().add(LuaAst.LuaLiteral("end")); @@ -148,7 +151,10 @@ public class LuaNatives { f.getParams().add(LuaAst.LuaVariable("x", LuaAst.LuaNoExpr())); f.getBody().add(LuaAst.LuaLiteral("if x >= 0.0 and x < 2147483648.0 then return x // 1 | 0 end")); f.getBody().add(LuaAst.LuaLiteral("if x < 0.0 and x > -2147483649.0 then return -(-x // 1 | 0) end")); - f.getBody().add(LuaAst.LuaLiteral("return R2I(x)")); + // R2I saturates outside the range (measured on the 3.0.0 client), and NaN is 0 as in the interpreter + f.getBody().add(LuaAst.LuaLiteral("if x > 0.0 then return 2147483647 end")); + f.getBody().add(LuaAst.LuaLiteral("if x < 0.0 then return -2147483648 end")); + f.getBody().add(LuaAst.LuaLiteral("return 0")); }); addNative("__wurst_rawToInteger", f -> { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/InterpreterTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/InterpreterTests.java index 0c0d27998..4fd3d00a2 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/InterpreterTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/InterpreterTests.java @@ -406,11 +406,17 @@ public void derivedAndLessCommonConstants() { ); } - /** Every expected value here was measured on the 3.0.0 client. */ + /** + * Every expected value here was measured on the 3.0.0 client. A real past 2^31 is computed (w keeps it from + * folding): Jass reads a real literal past 2^31 wrapped (the literal 3000000000. is negative), which the + * interpreter does not model, and which is what R2S, R2SW and R2I of such a literal show. + */ @Test public void stringNativesAsMeasured() { test().withStdLib().executeProg(true).testLua(false).lines( "package Test", + "@noinline function w(real x) returns real", + " return x", "function measured(string name, string actual, string expected)", " if actual != expected", " testFail(name + \" gave \" + actual)", @@ -433,11 +439,8 @@ public void stringNativesAsMeasured() { " measured(\"R2S carry\", R2S(0.9996), \"1.000\")", " measured(\"R2S negative\", R2S(-1.5), \"-1.500\")", " measured(\"R2S negative to zero\", R2S(-0.0004), \"-0.000\")", - " measured(\"R2S wraps\", R2S(3000000000.), \"-1294967296.000\")", - " measured(\"R2S negative wraps\", R2S(-3000000000.), \"1294967296.000\")", - " measured(\"R2S 1e10\", R2S(10000000000.), \"1410065408.000\")", - // Past 2^56 a real is a multiple of 2^32, so its integer part wraps to 0. - " measured(\"R2S and R2SW beyond the long range\", R2S(100000000000000000000.) + \",\" + R2SW(100000000000000000000., 1, 2), \"0.000,0.00\")", + " measured(\"R2S of a computed 3e9\", R2S(w(30000.) * w(100000.)), \"3000000000.000\")", + " measured(\"R2S of a computed -3e9\", R2S(-(w(30000.) * w(100000.))), \"-3000000000.000\")", " measured(\"R2SW padded\", R2SW(1.5, 8, 2), \" 1.50\")", " measured(\"R2SW negative padded\", R2SW(-1.5, 8, 3), \"- 1.500\")", " measured(\"R2SW precision 0\", R2SW(1.5, 2, 0), \" 2.0\")", @@ -446,12 +449,13 @@ public void stringNativesAsMeasured() { " measured(\"R2SW negative to zero\", R2SW(-0.4, 1, 0), \"-0.0\")", " measured(\"R2SW integer\", R2SW(7., 4, 0), \" 7.0\")", " measured(\"R2SW carry\", R2SW(0.9996, 1, 3), \"1.000\")", - // Wraps like R2S: 3e9 is measured, and -3e9 wraps to the positive value R2S prints for it. - " measured(\"R2SW wraps\", R2SW(3000000000., 1, 1) + \",\" + R2SW(-3000000000., 1, 3), \"-1294967296.0,1294967296.000\")", + " measured(\"R2SW of a computed 3e9\", R2SW(w(30000.) * w(100000.), 1, 1) + \",\"" + + " + R2SW(-(w(30000.) * w(100000.)), 1, 3), \"3000000000.0,-3000000000.000\")", " measured(\"R2SW smallest width\", R2SW(1., -2147483647 - 1, 1), \"1.0\")", " measured(\"R2I truncates\", I2S(R2I(1.9)) + \",\" + I2S(R2I(-1.9)), \"1,-1\")", - " measured(\"R2I wraps\", I2S(R2I(10000000000.)) + \",\" + I2S(R2I(-10000000000.)), \"1410065408,-1410065408\")", - " measured(\"R2I beyond the long range\", I2S(R2I(100000000000000000000.)) + \",\" + I2S(R2I(-100000000000000000000.)), \"0,0\")", + " measured(\"R2I saturates\", I2S(R2I(w(50000.) * w(100000.))) + \",\" + I2S(R2I(-(w(50000.) * w(100000.))))," + + " \"2147483647,-2147483648\")", + " measured(\"R2I saturates far out\", I2S(R2I(w(50000.) * w(100000.) * w(50000.) * w(100000.) * w(4.))), \"2147483647\")", " measured(\"SubString end before start\", SubString(\"abcdef\", 3, 1), \"def\")", " measured(\"SubString empty range\", SubString(\"abcdef\", 3, 3), \"\")", " measured(\"SubString at the end\", SubString(\"abcdef\", 6, 8), \"\")", 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 29735f3af..cfc301046 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 @@ -7630,6 +7630,24 @@ public void realDivisionByZeroStopsTheInterpreters() { } } + /** + * Measured on the 3.0.0 client, R2I saturates outside the 32-bit range, in Jass and in the game's Lua alike: a + * computed 5000000320 is 2147483647, its negative -2147483648. The interpreters and the test Lua's R2I agree, so + * a real modulo whose quotient leaves the range is the same everywhere too. + */ + @Test + public void r2iSaturatesInEveryRun() { + test().testLua(true).luaOnly(false).withStdLib().executeProg().lines( + "package Test", + "@noinline function w(real x) returns real", + " return x", + "init", + " let big = w(50000.) * w(100000.)", + " if R2I(big) == 2147483647 and R2I(-big) == -2147483648 and R2I(big * big) == 2147483647", + " and big % 1. > 2000000000.", + " testSuccess()"); + } + @Test public void realModIsModuloRealsFormulaInEveryRun() { test().testLua(true).luaOnly(false).withStdLib().executeProg().lines( diff --git a/docs/WC3_RUNTIME.md b/docs/WC3_RUNTIME.md index 37695e259..482f68f33 100644 --- a/docs/WC3_RUNTIME.md +++ b/docs/WC3_RUNTIME.md @@ -44,9 +44,9 @@ Every client simulates the same game from the same inputs; anything that makes t * **measured, 3.0.0** Jass literals and `S2R` are not correctly rounded and the two disagree: `0.1` reads one float high, `1.1` one float low, `S2R("100.0")` one above 100. Short exact binary fractions (`0.5`, `0.75`, `100.0`, `123456.78125`) read exactly; integer parts past 2^24 are truncated. This is why `SimpleRewrites.foldRealExactly` folds only exact literals and exact results. * **measured, 3.0.0** Lua reals have a 24-bit mantissa, not 53: `(2^24 + 1) - 2^24` is 0, the literal `16777217.` reads as 16777216, `0.9999999999999999` reads as exactly 1, and `0.7 + 0.1 + 0.1 + 0.1` computes to 1 - 2^-23. Literals are rounded to nearest and each arithmetic result is truncated, which neither double nor 32-bit float arithmetic reproduces. One `R2I` result is not evidence of a precision: `R2I(0.7 + 0.1 + 0.1 + 0.1)` is 0 for that reason, not because of doubles. -* **measured, 3.0.0** `R2I` and the arithmetic `x // 1 | 0` agree on every value probed, the 32-bit edges and values outside the range included (`LuaNativeLowering.lowerRealToInt`). +* **measured, 3.0.0** `R2I` truncates, and saturates outside the 32-bit range, in Jass and in the game's Lua alike: a computed 5000000320 is 2147483647, its negative -2147483648, a computed 1e20 2147483647. Inside the range the arithmetic `x // 1 | 0` agrees with it; outside, the Lua lowering answers the bound itself (`LuaNativeLowering.lowerRealToInt`, `ExprTranslation.realToInt`). Integer arithmetic wraps at 32 bits on both (`2147483647 + 1` is -2147483648). +* **measured, 3.0.0** `R2S` and `R2SW` print the whole integer part of a computed value (3e9 is `3000000000.000`, -3e9 `-3000000000.000`); a computed 5e9 prints as `5000000320.000`, which the interpreter does not model. Jass reads a real literal whose integer part passes 2^31 wrapped to 32 bits, signed: the literal `3000000000.` is negative and `10000000000.` reads as 1410065408. What `R2S`, `R2SW` and `R2I` of such a literal answer is that, not the native; the interpreter does not model it, and Lua reads the literal correctly. * **measured, 3.0.0** A real modulo is Blizzard.j's `ModuloReal` on Jass, `a - I2R(R2I(a / b)) * b` plus `b` when negative. The same formula in the game's Lua gives Jass's result for every dividend both read alike (54 of 54 probed); Lua's float `%` and `math.fmod` do not: `360. % 7.` is 3.0000153 with either and 3 in Jass, `7.5 % 360.` is 7.4999995 and 7.5, and `-7.5 % 2.5` is 2.4999993 with fmod and its correction and 0 in Jass. The integer `%` agrees with Jass's `ModuloInteger` for a positive divisor on every value probed, the 32-bit edges included. So a real modulo is that formula with `R2I` as Lua arithmetic, and only an integer one by a positive literal is `%` (`LuaNativeLowering`). - * **measured, 3.0.0** A real division by zero stops the thread in Jass: `1. / 0.`, `-1. / 0.`, `0. / 0.`, `5. % 0.` and `ModuloReal(5., 0.)` all do. The game's Lua goes on: `1. / 0.` is 1.7014e38 (the largest float, not an infinity), `-1. / 0.` its negative, `0. / 0.` is 1, and the Lua real modulo by 0 answers a number. The interpreters stop as Jass does (`WurstOperator.divideReal`); the Lua backend adds no check, which would cost every real division. ## Establishing a fact From 441416fc5751653fc5e0590b0669372bf84c96c2 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 11 Oct 2026 16:56:54 +0200 Subject: [PATCH 3/3] Test the Jass interpreter's real division by zero on its own --- .../tests/LuaBackendAuditTests.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 cfc301046..2e385e589 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 @@ -7630,6 +7630,27 @@ public void realDivisionByZeroStopsTheInterpreters() { } } + /** + * The Jass interpreter divides through {@code ILconstNum.div}, which a program never reaches when the IM + * interpreter stops first, so it is checked on its own: a real division by zero stops, an integer one too. + */ + @Test + public void theJassInterpretersRealDivisionByZeroStops() { + de.peeeq.wurstscript.intermediatelang.ILconstReal zero = de.peeeq.wurstscript.intermediatelang.ILconstReal.create(0f); + org.testng.Assert.assertThrows(ArithmeticException.class, + () -> de.peeeq.wurstscript.intermediatelang.ILconstReal.create(1f).div(zero)); + org.testng.Assert.assertThrows(ArithmeticException.class, + () -> de.peeeq.wurstscript.intermediatelang.ILconstReal.create(0f).div(zero)); + org.testng.Assert.assertThrows(ArithmeticException.class, + () -> de.peeeq.wurstscript.intermediatelang.ILconstInt.create(1).div(zero)); + org.testng.Assert.assertThrows(ArithmeticException.class, + () -> de.peeeq.wurstscript.intermediatelang.ILconstReal.create(1f) + .div(de.peeeq.wurstscript.intermediatelang.ILconstInt.create(0))); + assertEquals(0.5f, ((de.peeeq.wurstscript.intermediatelang.ILconstReal) + de.peeeq.wurstscript.intermediatelang.ILconstReal.create(1f).div( + de.peeeq.wurstscript.intermediatelang.ILconstReal.create(2f))).getVal(), 0f); + } + /** * Measured on the 3.0.0 client, R2I saturates outside the 32-bit range, in Jass and in the game's Lua alike: a * computed 5000000320 is 2147483647, its negative -2147483648. The interpreters and the test Lua's R2I agree, so