fastCbrt passes a JavaScript double 1/3 into fastPow:
export function fastPow(x: number, p: number): number {
return fastPow2(f32(p * fastLog2(x))); // p is a double here
}
export function fastCbrt(x: number): number {
return fastPow(x, 1 / 3); // 0.3333333333333333, not f32
}
The game's exponent is a float, so the multiply happens at single precision.
Read from the binary, not inferred
The symbol is __ZN4Math7powSafeEff = Math::powSafe(float, float) at 0x102955a88 in
the 2.1.12 arm64 binary - both parameters are float. The non-integral-exponent path
inlines fastapprox log2 (the same 0xbfbfbf75 / 0x3eb444f9 / 0xbfdce9a3 constants we
already carry) and then multiplies by the exponent at single precision:
102955ba8: fdiv s2, s3, s2
102955bac: fadd s0, s0, s2 ; s0 = fastlog2(x)
102955bb0: fmul s0, s0, s1 ; <-- s1 is the exponent, SINGLE precision
f32(1/3) is 0.3333333432674408; the double is 0.3333333333333333. They are not equal,
so f32(double * log2) and f32(f32 * log2) can round to different f32 values.
Measured divergence
Current fastCbrt(x) vs fastPow(x, Math.fround(1/3)), 200,000 log-uniform samples over
x in [1e1, 1e8] (the band resource quantities live in):
6430 / 200000 = 3.215% of inputs differ.
Worst relative 2.867e-5, worst absolute 7.813e-3, at x ~= 2.023e7.
One rival explanation, tested and REFUTED
Math::powSafe also has an integral-exponent fast path (fcvtzs/scvtf round-trip at
0x102955ab0, then exponentiation by squaring) that skips fastapprox entirely. Our
fastPow has two other call sites that pass an integer octaves
(multioctaveNoise.ts:73, trees/treeField.ts:109), so the obvious worry is that the
multioctave RMS normalisation should be using exact squaring.
It should not. Swapping the norm to f32 exponentiation-by-squaring and re-measuring
against oracle-multioctave.seed123456.json (7 cases x 38 positions, persistences
0.45/0.5/0.65/0.7/0.9 - i.e. covering the non-power-of-two cases where the two differ most):
| norm |
worstNear |
worstFar |
fastPow (current) |
2.630e-5 |
1.170e-4 |
| exponentiation by squaring |
5.332e-4 |
3.385e-4 |
| gate |
< 5e-5 |
< 2e-4 |
Squaring is 20x worse and fails both gates. So the noise machine's normalisation does
not route through Math::powSafe, and fastPow's current behaviour is correct at both
of its integer-exponent call sites. This issue is therefore only about the 1/3
exponent in fastCbrt.
What is still open
Whether the resource subsystem (spot_height / blob_amplitude / the spot-selection cone
radius - a different code path from the noise-expression evaluator) actually calls
Math::powSafe. The measurement above says the noise machine does not; it says nothing
about the resource autoplacer. Settle that before changing fastCbrt, because the fix is
only correct if powSafe is the function on that path.
The fix, if confirmed, is one character-class change: fastPow(x, f32(1 / 3)).
Related: the fastApprox per-op rounding change in #161, and #162 on why a
tolerance-based suite cannot see any of this.
fastCbrtpasses a JavaScript double1/3intofastPow:The game's exponent is a float, so the multiply happens at single precision.
Read from the binary, not inferred
The symbol is
__ZN4Math7powSafeEff=Math::powSafe(float, float)at0x102955a88inthe 2.1.12 arm64 binary - both parameters are
float. The non-integral-exponent pathinlines fastapprox
log2(the same0xbfbfbf75/0x3eb444f9/0xbfdce9a3constants wealready carry) and then multiplies by the exponent at single precision:
f32(1/3)is0.3333333432674408; the double is0.3333333333333333. They are not equal,so
f32(double * log2)andf32(f32 * log2)can round to different f32 values.Measured divergence
Current
fastCbrt(x)vsfastPow(x, Math.fround(1/3)), 200,000 log-uniform samples overxin[1e1, 1e8](the band resource quantities live in):One rival explanation, tested and REFUTED
Math::powSafealso has an integral-exponent fast path (fcvtzs/scvtfround-trip at0x102955ab0, then exponentiation by squaring) that skips fastapprox entirely. OurfastPowhas two other call sites that pass an integeroctaves(
multioctaveNoise.ts:73,trees/treeField.ts:109), so the obvious worry is that themultioctave RMS normalisation should be using exact squaring.
It should not. Swapping the norm to f32 exponentiation-by-squaring and re-measuring
against
oracle-multioctave.seed123456.json(7 cases x 38 positions, persistences0.45/0.5/0.65/0.7/0.9 - i.e. covering the non-power-of-two cases where the two differ most):
fastPow(current)Squaring is 20x worse and fails both gates. So the noise machine's normalisation does
not route through
Math::powSafe, andfastPow's current behaviour is correct at bothof its integer-exponent call sites. This issue is therefore only about the
1/3exponent in
fastCbrt.What is still open
Whether the resource subsystem (
spot_height/blob_amplitude/ the spot-selection coneradius - a different code path from the noise-expression evaluator) actually calls
Math::powSafe. The measurement above says the noise machine does not; it says nothingabout the resource autoplacer. Settle that before changing
fastCbrt, because the fix isonly correct if
powSafeis the function on that path.The fix, if confirmed, is one character-class change:
fastPow(x, f32(1 / 3)).Related: the
fastApproxper-op rounding change in #161, and #162 on why atolerance-based suite cannot see any of this.