diff --git a/Jint.Tests/Runtime/NumberTests.cs b/Jint.Tests/Runtime/NumberTests.cs
index f677ad63b..66b37f46e 100644
--- a/Jint.Tests/Runtime/NumberTests.cs
+++ b/Jint.Tests/Runtime/NumberTests.cs
@@ -266,6 +266,80 @@ public void IntegerMultiplicationPreservesNegativeZero()
Assert.Equal("true,true,true,true,true,true,true,true,true,true,true,true,true,true", result);
}
+ [Fact]
+ public void IntegerRemainderLanesPreserveSignAndSpecialCases()
+ {
+ var engine = new Engine();
+ // Number::remainder: the result takes the dividend's sign, so a zero remainder from a
+ // negative (or -0) dividend is -0. The unboxed raw-double lanes (compound `x %= y`,
+ // statement `lhs = a % b`, fused `x % c === c`) compute integral operands with int32
+ // math and must reproduce this exactly, deferring NaN/fractional/zero-divisor/out-of-range
+ // operands to fmod. The loop over slot-stored locals keeps the lanes engaged and their
+ // slot caches hot; every check pushes true.
+ var result = engine.Evaluate("""
+ (function () {
+ var r = [];
+ for (var i = 0; i < 3; i++) {
+ // compound `x %= y` shape
+ var a = -7, b = 2;
+ a %= b;
+ r.push(a === -1);
+ var c = 7, d = -2;
+ c %= d;
+ r.push(c === 1);
+ var negFour = -4, two = 2;
+ negFour %= two;
+ r.push(Object.is(negFour, -0));
+ var four = 4;
+ four %= two;
+ r.push(Object.is(four, 0));
+ var n = 5;
+ n %= 0;
+ r.push(Number.isNaN(n));
+ var f = 5.5;
+ f %= two;
+ r.push(f === 1.5);
+ var min = -2147483648, negOne = -1;
+ min %= negOne;
+ r.push(Object.is(min, -0));
+ var negZero = -0;
+ negZero %= two;
+ r.push(Object.is(negZero, -0));
+
+ // `lhs = a % b` statement shape
+ var res = 0;
+ var negFourB = -4, fourB = 4, sevenB = 7, negTwoB = -2, minB = -2147483648, fracB = 5.5;
+ res = negFourB % 2;
+ r.push(Object.is(res, -0));
+ res = fourB % 2;
+ r.push(Object.is(res, 0));
+ res = sevenB % negTwoB;
+ r.push(res === 1);
+ res = minB % negOne;
+ r.push(Object.is(res, -0));
+ res = fracB % 2;
+ r.push(res === 1.5);
+ res = sevenB % 0;
+ r.push(Number.isNaN(res));
+
+ // fused `x % constant === constant` equality shape (a -0 remainder
+ // compares equal to 0 under IEEE ===)
+ var e1 = -4, e2 = 4, e3 = -7, e4 = 7, e5 = 5, e6 = 5.5, e7 = -2147483648;
+ r.push(e1 % 2 === 0);
+ r.push(e2 % 2 === 0);
+ r.push(e3 % 2 === -1);
+ r.push(e4 % -2 === 1);
+ r.push(e5 % 0 !== 0);
+ r.push(e6 % 2 === 1.5);
+ r.push(e7 % -1 === 0);
+ }
+ return r.join(',');
+ })()
+ """).AsString();
+
+ Assert.Equal(string.Join(",", Enumerable.Repeat("true", 63)), result);
+ }
+
// The following tests guard the primitive-receiver method resolution that skips allocating a
// Number/Boolean/BigInt wrapper on `primitive.method()`. The wrapper was only ever a lookup
// vehicle (the this-value passed to the callee is the primitive, boxed at call time for sloppy
diff --git a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs
index d7b6c07d9..446bf4e5e 100644
--- a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs
+++ b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs
@@ -402,8 +402,9 @@ private bool TryCompoundUnboxed(EvaluationContext context)
result = left / right;
break;
case Operator.RemainderAssignment:
- // IEEE 754 remainder (fmod) matches the ECMAScript algorithm for all special cases
- result = left % right;
+ // int32 lane avoids native fmod for integral operands; the fallback IEEE 754
+ // remainder (fmod) matches the ECMAScript algorithm for all special cases
+ result = RemainderUnboxed(left, right);
break;
case Operator.BitwiseAndAssignment:
result = TypeConverter.ToInt32(left) & TypeConverter.ToInt32(right);
@@ -887,8 +888,9 @@ private bool TryAssignNumeric(EvaluationContext context)
result = left / right;
break;
case Operator.Remainder:
- // IEEE 754 remainder (fmod) matches the ECMAScript algorithm for all special cases
- result = left % right;
+ // int32 lane avoids native fmod for integral operands; the fallback IEEE 754
+ // remainder (fmod) matches the ECMAScript algorithm for all special cases
+ result = RemainderUnboxed(left, right);
break;
default:
return false;
diff --git a/Jint/Runtime/Interpreter/Expressions/JintBinaryExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintBinaryExpression.cs
index c3dc0797f..7a8490982 100644
--- a/Jint/Runtime/Interpreter/Expressions/JintBinaryExpression.cs
+++ b/Jint/Runtime/Interpreter/Expressions/JintBinaryExpression.cs
@@ -649,11 +649,11 @@ private static bool TryReadIndex(JintIdentifierExpression? indexIdentifier, ref
///
/// Fused lane for the `identifier % numericConstant == numericConstant` test — the
/// stopwatch.js if-chain shape. Reads the identifier through the same slot/global arms as
- /// and computes the whole test on raw doubles.
- /// C# double % implements Number::remainder exactly, and the ways integer and double
- /// remainders can disagree (−0 vs +0 results) are erased by the equality consumer
- /// (IEEE == treats ±0 as equal), so the fused result is spec-exact for any proven-Number
- /// operand: NaN dividends/divisors (and % 0) compare false, v % ±Infinity == v.
+ /// and computes the whole test on raw doubles
+ /// via , which implements Number::remainder
+ /// exactly (int32 lane for integral operands, IEEE 754 fmod otherwise), so the fused
+ /// result is spec-exact for any proven-Number operand: NaN dividends/divisors (and % 0)
+ /// compare false, v % ±Infinity == v, and ±0 results compare equal under IEEE ==.
///
private protected struct ModuloEqualityLane
{
@@ -700,7 +700,7 @@ public bool TryEvaluate(EvaluationContext context, out bool equal)
}
}
- equal = value % _divisor == _expected;
+ equal = RemainderUnboxed(value, _divisor) == _expected;
return true;
}
}
diff --git a/Jint/Runtime/Interpreter/Expressions/JintExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintExpression.cs
index a99f4d19d..86ec549d8 100644
--- a/Jint/Runtime/Interpreter/Expressions/JintExpression.cs
+++ b/Jint/Runtime/Interpreter/Expressions/JintExpression.cs
@@ -252,6 +252,34 @@ protected static JsValue Remainder(EvaluationContext context, JsValue left, JsVa
return result;
}
+ ///
+ /// Number::remainder over raw doubles with an int32 fast lane — the raw-double lanes
+ /// otherwise pay a native fmod call even for the dominant integral loop-counter shapes.
+ /// Anything failing the round-trip gate (NaN, ±Infinity, fractional values, operands
+ /// beyond int32 range, a zero divisor) falls back to IEEE 754 %, which matches the
+ /// ECMAScript algorithm for all special cases.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected static double RemainderUnboxed(double left, double right)
+ {
+ // int32-representable check: NaN/±Infinity/fractional values fail the round-trip
+ // equality and defer to fmod, as does a zero divisor (fmod gives the spec NaN);
+ // a -0 dividend round-trips to 0 and is re-signed below
+ var leftInteger = (int) left;
+ var rightInteger = (int) right;
+ if (leftInteger == left && rightInteger == right && rightInteger != 0)
+ {
+ // long math: int.MinValue % -1 raises a hardware overflow in 32-bit division
+ var modulo = (long) leftInteger % rightInteger;
+ // Number::remainder gives the result the dividend's sign, so a zero remainder
+ // from a negative (or -0) dividend is -0 — integer math cannot represent it,
+ // test the sign bit of the original double (left is proven non-NaN by the gate)
+ return modulo == 0 && BitConverter.DoubleToInt64Bits(left) < 0 ? -0.0 : modulo;
+ }
+
+ return left % right;
+ }
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected static JsValue Divide(EvaluationContext context, JsValue left, JsValue right)
{