Commit Graph
420 Commits
Author SHA1 Message Date
pstef 38ba87f359 Test: pin the multiplier deficit in the upper binade
The Booth predicate is keyed to fixed bit positions of ft, but the
truncation column moves one bit between the binades -- and every
zero-tail row I measured sat in the lower binade, at fs = 2^23. So the
region where the decrement fires with the product in the upper binade
had never been observed on silicon. The fpmul3 capture already had it.
The counts and the row provenance are at the tables.

One dead end worth recording, since the data invites it: do not split
this by binade, split it by T. fs = 0xFFFFFF looks like the predicate
carries no information there (0.1907% low with Booth on, 0.1976% with it
off), but it has exactly one T == 0 row, so that is measuring the
unmodelled 0 < tail < deficit class instead. At fs = 0x800001, lower
binade and also almost entirely that class, Booth is strictly necessary:
13,248/13,248 on, 0/262,144 off.
2026-08-04 21:13:34 +02:00
pstef e1f3048cc4 Optimization: pin the multiplier's predicate mask
emitDefectiveFmul materialised the 0x2AA Booth-digit mask on every
mode-3 multiply (mov + fmov). It is now placed into d10 for the whole
JIT session by _DynGen_EnterRecompiledCode, next to the s8/s9 clamp
scalars and under the same AAPCS64 argument: the low 64 bits of d8-d15
are callee-saved, so a pinned constant survives every C call with no
compile-time tracking. Six instructions become four:

    cmtst d30, d11, d10        ; d11 = narrow guest ft, d10 = the parked mask
    fmul  d0, d0, d1
    fcmeq d31, d0, #0.0
    bic   v30.8b, v30.8b, v31.8b
    add   d0, d0, d30

Why a pinned callee-saved register beats a caller-saved one behind a
liveness flag, and why q10 specifically, is on NEON_RESERVED_FPU_MULMASK
in iCore-arm64.h. It costs one of the six call-surviving FPR homes.
2026-08-04 21:13:32 +02:00
pstef bfc99f2bf3 Accuracy: give clamp mode 3 the EE multiplier's one-ULP deficit
The console's multiply array does not round correctly. When the exact
product has nothing below the single's ULP to absorb it, the result
comes back exactly one step closer to zero -- and whether it does is
decided by ft's mantissa alone, so mul.s is not commutative. iFPUd, the
eeClampMode:3 codegen, computed the IEEE product; the interpreter has
modelled the law since the previous commit.

Six instructions per multiply, at both emit sites: recMULop, and
recMaddsub's multiply stage, which round through different helpers. The
mechanism, the FPCR dependency it rests on, and the one term of the
measured predicate it knowingly drops are documented at
emitDefectiveFmul.

kGuardMaskWitnesses moved on 35 of its 72 rows -- fs is 1.0 throughout,
so every product there is zero-tailed. Each was re-derived against the
measured law rather than re-pinned against the emitter that moved it;
the one row where the dropped boundary term decides is called out at the
table.
2026-08-04 21:11:59 +02:00
pstef 9d7d11cd0e Fix: model the EE multiplier's one-ULP deficit in the interpreter
The console's multiply array is not a correctly-rounding multiplier: it
comes back exactly one step closer to zero on a large fraction of
operands, and which operands depends on operand order. Upstream states
the rule in a comment (x86/iFPU.cpp) and never tests it; FpuMulHack is
a one-point sample of it.  What is modelled, what deliberately is not,
and where the rule was measured are at eeMulDefectiveFt.

All six multiply forms route their product through the new eeMulProduct,
which also localises the fpuDouble() calls the sites used to make
inline. MADDA/MSUBA gain a temp for it; the accumulate itself is
unchanged, and still does not round-trip the product through fpuDouble
the way MADD/MSUB do.

MulSFpuMulHackOffGivesNativeProduct asserted the opposite -- that the
IEEE product is the console's. Silicon returns 0x3F490FDA for those
operands and 0x3F490FDB reversed, so the gamefix matched hardware in
both orders all along.

The deficit only reaches what fpuDouble() hands it. An exponent-0xff
operand is clamped to +/-Fmax before the multiply and the product
saturates at FLT_MAX, so this engine still has no EE top binade; that
gap is fpuDouble's and is untouched here.
2026-08-04 20:26:32 +02:00
pstef ebf7124f25 Test: pin the MADD family's guard mask and rounding
Two new tests exist so that the format-churn work that follows has
something to fail against.
2026-08-04 20:19:38 +02:00
bmdhacks 8f67945c9e Fix: RSQRT.S's zero-divisor sign comes from the dividend
Two sign rules, and they are not the same rule. DIV.S takes the xor of
both operands. RSQRT.S takes the DIVIDEND's sign alone -- it divides by
sqrt(|Ft|), so the divisor has no sign left to contribute by the time the
division happens. Both of our engines took Ft's sign, and the arm64
emitter was alone among recompilers in it: x86 recRSQRThelper1 (iFPU.cpp)
has always taken Fs's.

The console rows that separate the rules: rsqrt(+0, -0) is positive and
rsqrt(-0, -0) is negative on silicon; an xor rule, or Ft's sign, flips
both. Fixing the sign moves the arm64 emitter's agreement with the
console and with the x86 JIT, and keeps the two local engines in exact
agreement on the whole zero path.

The MAGNITUDE stays at the fast tier's +/-fMax saturation. Silicon
returns 0x7FFFFFFF there -- the EE's real maximum, one binade up -- but
that is the top-binade compromise shared by every fast-path op, not the
sign rule, and it moves as a class or not at all.

Pinned by EeRecFpuRsqrt.ZeroDivisorSignComesFromTheDividend, six rows
across both zero-sign combinations and nonzero dividends, both engines
diffed. DenormalDivisorTreatedAsZero's expectation flips to the new rule.

Idea by pstef.
2026-08-02 22:35:45 -07:00
bmdhacks 665533c738 Fix: MAX.S/MIN.S select a word on silicon, the fast path computed one
The EE does not take a maximum, it picks one: the two raw words are
ordered by (sign, magnitude) and the winner's 32 bits are written
through untouched. That is fp_max/fp_min in FPU.cpp, and it is what the
interpreter and the DOUBLE tier have always done.

The arm64 fast path clamped both operands to +/-fMax and then used
Fmaxnm/Fminnm, which loses two whole operand classes:

  denormals    Fmaxnm/Fminnm are arithmetic ops, so FPCR.FZ flushed the
               operand first and the winner's word was destroyed:
               max(0x00000001, 0x00000000) read back 0x00000000 where
               the console says 0x00000001.
  exponent 255 the clamp folded the entire top binade onto 0x7F7FFFFF:
               max(0x7F7FFFFF, 0x7FFFFFFF) read back 0x7F7FFFFF where
               the console says 0x7FFFFFFF.

Both classes are exactly what ABS.S/NEG.S carried until cbf04acba1, for
exactly the same two reasons.

Replaced with an integer ordering key, k(x) = x ^ ((x >>s 31) >>u 1),
compared signed and resolved with a Csel between the untouched
originals -- nine instructions, and no arithmetic for FZ to act on. The
scratch registers stay inside fpuEmitGuardedAddSub's contract
(w0/w1/w8/w9), so a resident FCR31 in the x2-x7/x14/x15 pool is safe.

Measured on the 1147-case SCPH-90000 capture, corpus v3:

  MAX 28/66 -> 66/66, MIN 50/66 -> 66/66
  whole corpus, result axis: 755 -> 809 of 1147
  54 cases changed, 54 onto the console value, 0 away, 0 outside MAX/MIN
  FCR31 axis unmoved at 978

and 66/66 on both ops in all five regimes measured: eeClampMode 0/1/2,
fpuFullMode, and DenormalsAreZero off. The interpreter column is
untouched at 1067 and remains the control.

CHECK_FPU_OVERFLOW now gates no arm64 emitter path at all -- SQRT.S gave
up its operand clamp in 1a09344ba6, ABS.S in cbf04acba1, and MAX/MIN
here. The knob is still live on x86 and still set from the GameDB, but
eeClampMode 0 and 1 emit identical code on this port. The liveness
witness that rode on it is retired in place, with that stated, rather
than replaced by one that cannot fail.

Four tests in ee_rec_fpu_tests.cpp asserted the clamp and are inverted
here; their premise was that the x86 JIT is the FPU-clamp oracle, which
the capture refutes (upstream's fast tier is wrong on 41 MAX and 22 MIN
of the same cases). New file ee_fpu_minmax_console_tests.cpp carries the
54 distinct console triples, the aliased register forms, the O|U clear
against capture rows 734/735, and the FCR31-residency hazard. It fails
on 3 of 6 tests against the unpatched emitter and passes on all 6 here.

Idea by pstef.
2026-08-02 22:35:45 -07:00
bmdhacks 5dbb14458d Fix: the fast path never cleared the O and U cause flags
The EE clears the O and U CAUSE bits (the sticky SO/SU survive) on every
op that can raise them, whether or not it does: ADD, SUB, MUL, the four
A-forms, the four multiply-accumulates, and MAX/MIN/ABS/NEG, which clear
the pair and do nothing else. DIV, SQRT, RSQRT and MOV leave both alone.
Measured on FCR31-seeded capture rows: ABS, NEG, ADD, ADDA, MADD, MSUB,
MUL, MULA, MAX and MIN all read back 0x0183C079 where the console gives
0x01830079; SUB, SUBA, MADDA and MSUBA have no seeded row and follow on
the interpreter's authority (checkOverflow/checkUnderflow/clearFPUFlags
clear the pair on all fourteen).

The arm64 fast path cleared the pair only on ABS/NEG, so an O or U raised
by an earlier instruction stayed visible to every later cfc1 in the block.
The interpreter has always cleared them, which made this a live
JIT-vs-interp FCR31 divergence as well as a console one. x86 iFPU.cpp has
the identical defect -- the clear is commented out at 13 sites.

The clear goes FIRST in each emitter, before the op writes anything: the
fast path raises neither flag today so the order is not yet observable,
but an emitter that later learns to raise O must not have its flag wiped
by a clear placed after it. One Bic on the block-resident FCR31 per op.

RAISING O and U is a separate, harder obligation -- a correct raise needs
the exact magnitude of the result, which a saturating single cannot carry
-- and stays with the FULL tier and the DISABLED tripwires in the FCR
conformance file.

Pinned by EeFpuFcrConsoleConformance.EnginesAgreeOnTheOverflowFlagClear:
fourteen clearing ops plus the four leave-alone controls, both engines,
seeded with the capture's word.

Idea by pstef.
2026-08-02 22:35:45 -07:00
bmdhacks ac88a41f95 Fix: ABS.S/NEG.S clamped operands the console passes through
The arm64 fast path clamped both results to +/-fMax. The EE does neither:
ABS.S is `& 0x7fffffff` and NEG.S is `^ 0x80000000`, which is what the
interpreter has always done, what the FULL path (DOUBLE::recABS_S_xmm) has
always emitted, and what silicon does. The clamp corrupted 22 of the 54
ABS/NEG operands in the first-party capture, in two distinct ways:

  * exponent-255 in, +/-fMax out (16 rows). Those are ordinary large PS2
    floats, not infinities -- abs(7F800000) is 7F800000, not 7F7FFFFF.
  * denormal in, ZERO out (6 rows), on ABS only. Its clamp was an Fminnm,
    an ARITHMETIC op, so FPCR.FZ flushed the operand before the compare
    happened. NEG's clamp was an integer Smin/Umin and never did this,
    which is exactly why the defect showed on one op and not the other --
    and why an operand pool built only from exponent-255 patterns missed
    it entirely.

Fabs and Fneg alone are correct and total: non-arithmetic bit operations,
no exceptions, no flush, payloads through with only the sign changed.

Found while removing SQRT.S's operand clamp (1a09344ba6) -- same finding,
one op over. Note the upstream x86 JIT is wrong on the same 22 rows; both
interpreters are right on all 54. This aligns our JIT with our interpreter
and with the console, and diverges it from upstream-x86, which is not a
cost when upstream-x86 is not the reference.

Second, independent defect in the same two emitters, fixed here because it
lives on the lines being rewritten: the fast path never cleared the O and U
cause flags. Interp ABS_S/NEG_S call clearFPUFlags(FPUflagO | FPUflagU) and
the FULL path emits ClearOUFlags; only the fast path skipped it, so an
overflow raised by an earlier op survived an ABS.S. Capture rows 729/730
seed FCR31 with flags set and confirm it against silicon: FCR31 goes
0183C079 -> 01830079, which is hardware's value.

Verified over the full 1147-case corpus, both engines, stock regime, on top
of the SQRT fix: 34 engine-cases moved, all 34 onto the silicon value, 0
away, 0 outside the two expected classes, 2260 identical. The 22 ABS/NEG
moves are arm64-JIT-only -- the interpreter did not move, which is the
control that its console rows were not quietly re-fitted.

EeFpuAbsNegClamp.DISABLED_JitMatchesConsoleInEveryClampMode is graduated.
Its console table gains 8 rows from the first-party capture covering the
denormal and signalling-NaN shapes ps2autotests does not reach, tagged by
source; the interpreter leg passes on those rows both before and after this
change, which is what validates the transcription independently of the fix.

EeRecFpu.NegSPreservesSignOnPoisonedNan pinned the second of three answers
this op has had (clamp losing the sign -> clamp keeping it -> no clamp). It
is rewritten to pin the console's answer and now runs the engine diff,
since its premise that no rec matches the interpreter no longer holds.

Idea by pstef.
2026-08-02 22:35:45 -07:00
bmdhacks ef6d720e39 Fix: SQRT.S of an exponent-255 operand, by scaling instead of clamping
Exponent 255 is an ordinary binade on the EE -- no Inf, no NaN, and the
representable max is 0x7FFFFFFF rather than FLT_MAX -- so an exponent-255
operand never needed saturating. Both engines clamped it to +/-FLT_MAX
anyway (the interpreter inside fpuDouble, arm64 with an integer Umin gated
on CHECK_FPU_OVERFLOW, mirroring x86's xMIN.SS) and landed two binades
below the console:

    sqrt.s 7F800000  ->  5F7FFFFF, silicon 5F800000
    sqrt.s 7FFFFFFF  ->  5F7FFFFF, silicon 5FB504F3
    sqrt.s 7FC00000  ->  5F7FFFFF, silicon 5F9CC471

All three engines agreed with each other and none agreed with the console.
Agreement is a weaker property than accuracy and it was bought at the cost
of accuracy.

Both engines now compute sqrt(|Ft|/4)*2. sqrt halves exponents, so the
scaled operand (exponent field 253) and the doubled result are both
ordinary singles: this needs no wider format and so leaves the fast path
single-precision, which is what the fast path is for. 4 is an even power of
two, so its own square root is exact and the identity contributes no
rounding -- the sqrt remains the only rounding step. It is the same
power-of-two prescale ToDouble() already uses to carry these operands into
FULL mode, with the factor picked to suit sqrt.

Ungated, because there was no mode in which the old code was right: with
CHECK_FPU_OVERFLOW off the same operands came back as 0x7F7FFFFF instead,
wrong a different way. Nothing with exponent field <= 254 is affected --
the old Umin was already a no-op on those, the new branch is not taken.

The JIT lands on the silicon value on every exponent-255 shape, both
signs, plus the exponent-254 control -- expected values computed
independently by exact integer arithmetic (no host float), validated
against silicon on the six witnessed operands. The interpreter moves two
binades onto the same values except where the sqrt is inexact in single
precision: there it narrows under the ambient ChopZero rather than the
divide unit's round-to-nearest and sits one ULP below silicon. That gap
predates this change, is documented as CLASS 3 in the conformance file's
divergence list, and closes when the interpreter models the div-unit
rounding law.

RSQRT.S deliberately unchanged: its two clamped operands currently cancel
on rsqrt(2^128, 2^128), so unclamping only the sqrt breaks a row that is
right today. It is all-or-nothing and is a separate change.

The two conformance tests that pinned the clamp are rewritten to pin the
console value instead, keeping their anti-vacuity clauses and gaining an
exponent-254 negative control. EeFpuAbsNegClamp's liveness witness for
DisableFpuOverflow() rode on SQRT's gate; it moves to MAX.S, now the only
remaining CHECK_FPU_OVERFLOW-gated emitter path.

Idea by pstef.
2026-08-02 22:35:45 -07:00
bmdhacks 318dd102dc Tests: ABS.S/NEG.S against hardware — the EE never clamps them
The console says both are pure sign-bit operations. From ps2autotests
tests/cpu/ee_fpu/arithmetic.expected:

    abs 7fffffff: 7fffffff     neg 7fffffff: ffffffff
    abs ffffffff: 7fffffff     neg ffffffff: 7fffffff
    abs 7f800000: 7f800000     neg 7f800000: ff800000

An exponent-255 operand comes back exactly, sign bit aside. The
interpreter reproduces every console row; the arm64 recompiler does not.
recABS_S_xmm and recNEG_S_xmm call fpuClampResultPositive/fpuClampResult
with no CHECK_FPU_* gate, so exp-255 operands all collapse to
±0x7F7FFFFF and eeClampMode has no effect whatsoever — x86 at least gates
ABS on CHECK_FPU_OVERFLOW, arm64 gates neither op on anything.

That defect is pre-existing (present at the merge-base), so the JIT leg
lands as a DISABLED tripwire, not a failing test. It fails on 30 of its
60 assertions today — the 5 exponent-255 rows × 2 ops × 3 clamp modes —
so it is live, not vacuous, and it should pass unchanged once the clamp
is removed.

Also promotes what was a printf-only measurement probe into assertions:

- InterpMatchesConsoleInEveryClampMode: enabled must-not-regress control
  on the side that matches silicon.
- JitIgnoresEeClampModeForAbsAndNeg: pins the inertness itself, so wiring
  the gate up fails here and points at the tripwire instead of going
  unnoticed.
- DisableFpuOverflowReachesTheEmitter: liveness witness for the new
  harness knob. DisableFpuOverflow() is observationally a no-op on
  ABS.S/NEG.S precisely because they ignore the mode, so without this the
  switch would ship with nothing proving it reaches the emitter. SQRT.S
  is the discriminator — its operand clamp is gated on
  CHECK_FPU_OVERFLOW, giving 0x5F7FFFFF clamped vs 0x7F7FFFFF not.

1551 pass / 0 fail / 27 disabled.

Idea by pstef.
2026-08-02 22:35:45 -07:00
bmdhacks 525355bc58 Fix: SQRT.S raises invalid on -0 and negative denormals
Both this branch's engines gated SQRT.S's I|SI on `exp != 0 && sign`. The
console gates on the sign bit alone, so -0 and the negative denormals -- which
flush to -0 and produce a perfectly ordinary +0 -- raise invalid-operation
there too. That gate is why exactly those two operand classes lost the flag and
nothing else did.

From the first-party capture that records FCR31 alongside the result,
cases 227 and 236:

    sqrt 80000000 : console 00000000/01020041   both engines 00000000/01000001
    sqrt 80000001 : console 00000000/01020041   both engines 00000000/01000001

Read across all 38 SQRT.S rows the rule holds without exception: every
sign-set operand raises 01020041, every sign-clear one leaves 01000001,
whatever the exponent. Case 248, a POSITIVE qNaN, raises nothing -- it is the
sign bit and not "is this operand strange".

x86's recSQRT_S_xmm has always tested MOVMSKPS's sign bit alone (iFPU.cpp:1767),
which is why upstream-x86-jit is the one column in the capture that answers
both rows correctly, and the arm64 FULL-mode DOUBLE::recSQRT_S_xmm already
tested the sign alone too and was unaffected. So the fix is a deletion on both
sides -- the Tst(0x7F800000)/B.eq pair in the arm64 fast path, and the hoisting
of the flag set out of FPU.cpp's negative-normal arm. Neither touches the value
path or the |Ft| clamp the exponent-255 rows depend on.

Commit 6e1c28f fixed the value half of case 227 (the interpreter returned
_FtValUl_ & 0x80000000 and so answered -0 where the console answers +0). This
is the flag half of the same two rows, and case 236 is a second witness that
commit did not know about.

Verified bidirectionally. EeRecFpu.SqrtSInvalidFlagFollowsTheSignBitAlone is
the ten-row sign x exponent matrix from the capture, each engine scored on the
full FCR31 word: before the patch the -0 and -MIN_DENORM rows fail on both
engines with 01000001 against 01020041 and the other eight pass; after, all ten
pass.

The six positive rows are controls, and because the fix is a deletion they were
checked live rather than assumed: with the sign test deleted as well, all six
fail on both engines and the four negative rows still pass. Without them, a
deletion that went one step too far would raise I on every SQRT.S and nothing
in the suite would notice.

EeRecFpu.SqrtSOfNegativeZeroIsPositiveZero had asserted the opposite -- "the
zero path is not the negative path: no I|SI" -- on nothing but the two engines
agreeing with each other. ps2autotests' sqrt.expected prints results only,
never FCR31, so it never supported that claim. Its value assertion stands; the
flag rule moves to the new test, and it keeps the one flag statement that is
still true, that SQRT.S never raises D.

recompiler_tests 1582 pass / 0 fail / 46 disabled, and the other four ctest
binaries rebuilt against the changed libpcsx2 and rerun: core_test 86,
common_test 31, gs_vertex_tests 21, mvu_progcache_versioning_tests 13.

Noticed in the same sweep and NOT addressed here, recorded as leads in the
capture's handoff: the arm64 JIT reports 01000001 on every RSQRT.S row in the
capture including ordinary-negative operands, where interp, x86-jit and
hardware all say 01020041 -- recRSQRT_S_xmm does contain the I|SI set, so that
looks like a lost flag write rather than a missing one. And hardware raises
I|SI, not D|SD, on RSQRT's 0/0 rows.

Idea by pstef.
2026-08-02 22:35:45 -07:00
bmdhacks b3dfef14e3 Fix: interp SQRT.S of -0.0 returns +0.0, not -0.0
IEEE-754 says sqrt(-0) is -0, and the interpreter said so too:

    _FdValUl_ = _FtValUl_ & 0x80000000;

The EE does not. ps2autotests tests/cpu/ee_fpu/sqrt.expected, captured on
hardware:

    sqrt 80000000/-0.00: 00000000/+0.00
    sqrt CF_NEGZERO:     00000000/+0.00

Both recompilers already agreed with the console by construction --
recSQRT_S_xmm takes |Ft| before the Fsqrt, so the sign is gone before the
zero case is reached -- which makes this an interp-vs-JIT divergence with
the interpreter on the deficient side.

Found by a randomized SQRT.S differential over signed zeros, +/-fMax and
full-range normals. It went unnoticed for as long as it did because every
hand-written SQRT.S case in the suite uses +/-4.0; the operand pool that
found it is going in with the next commit.

Bidirectional per the repo's evidence rule: the new test fails on the
unpatched tree with

    fpr[2]: JIT=0x0 INTERP=0x80000000

and passes with the patch. Full suite 1518 pass / 0 fail / 22 disabled.

Idea by pstef.
2026-08-02 22:24:39 -07:00
bmdhacks d80101329e Fix: SQRT.S clamps its operand in the arm64 fast path
recSQRT_S_xmm was the one emitter in iFPU-arm64.cpp that never clamped its
source. fpuClampInput has twelve call sites covering ADD/SUB/MUL/DIV/RSQRT and
the six accumulator forms; SQRT called it zero times. An exponent-255 Ft is an
ordinary large PS2 float, but it reaches the host as Inf, so Fsqrt returned Inf
and fpuClampResult flattened it to 0x7F7FFFFF -- two binades from the
interpreter's sqrt(fpuDouble(Ft)).

Found by the hardware capture landed in 47d910efa6, rows 44/45:

  sqrt +EEMAX : console 5fb504f3  interp 5f7fffff  jit 7f7fffff
  sqrt 2^128  : console 5f800000  interp 5f7fffff  jit 7f7fffff

Unlike the six operand-clamp rows beside them, these did not close under
CHECK_FPU_EXTRA_OVERFLOW -- there was no gate to turn on. That is what made it a
defect rather than the clamp-mode axis.

The gate is CHECK_FPU_OVERFLOW (eeClampMode >= 1, ON by default), not the
arithmetic family's CHECK_FPU_EXTRA_OVERFLOW. x86 recSQRT_S_xmm clamps at that
same lower threshold (iFPU.cpp:1777), and SQRT is alone in it: x86 gates RSQRT's
operand clamp on CHECK_FPU_EXTRA_OVERFLOW (recRSQRThelper1/2, iFPU.cpp:1835/1853),
which recRSQRT_S_xmm already matched, and every other x86 clamp reaches the FPU
through fpuFloat/fpuFloat2 under the same higher gate. Matching x86 rather than
DIV.S is what aligns all three engines in the mode games actually run in.
Direction per the standing rule: the interpreter was the side nearer the console,
so the recompiler moved. At eeClampMode 0 nothing is emitted, exactly as before.

One-sided, since Fabs has already made the operand non-negative -- the same
positive-only shape as x86's xMIN.SS. It is NOT Fminnm, which is what the first
cut of this used, and that was wrong: FPMinNum only prefers the number when the
other operand is a QUIET NaN, so a signalling operand goes down FPProcessNaNs
and comes back merely quieted, surviving the clamp. x86's MINSS returns src2 for
ANY NaN, and half of the EE's exponent-255 mantissa space is signalling, so
Fminnm covered only half the class the comment claims ("any Ft whose exponent
field is 255"). Measured exhaustively on this host over all 2^31 non-negative
operands, against a model of MINSS(x, +FLT_MAX):

    UMIN   mismatches vs MINSS: 0
    FMINNM mismatches vs MINSS: 4194303   (first at 7f800001)

End to end, sqrt(0x7F800001) came back 0x7F7FFFFF where the interpreter -- whose
fpuDouble switches on the exponent FIELD alone, mantissa irrelevant -- gives
0x5F7FFFFF. Same for 0xFF800001 and 0x7FBFFFFF. The capture's three SQRT rows
are a qNaN, an Inf and a finite number, so nothing in it could reach the
signalling half.

So the clamp is done in the integer domain instead: the operand is post-Fabs, so
bit 31 is clear, and over non-negative floats the IEEE ordering IS the unsigned
integer ordering. Umin against 0x7F7FFFFF clamps Inf, sNaN and qNaN alike and
passes every representable finite value -- exact MINSS agreement on every input,
at one instruction. Umin has no scalar form so it is a 2S vector op; only lane 0
carries the operand and the scalar Fsqrt that follows zeroes the rest.

Kept as a SQRT-local helper rather than folded into fpuClampResultPositive,
whose other caller recABS_S_xmm emits its clamp with no CHECK_FPU_* gate at all,
where the interpreter and the console both leave exponent-255 operands alone.
That is a separate pre-existing defect whose fix is to delete the clamp, not to
change which wrong answer it produces; editing the shared helper would have
moved ABS.S's output for an unfixed case. Verified unchanged: ABS.S/NEG.S still
diverge on the same 30 of 48 rows (EeFpuAbsNegClamp.DISABLED_DumpAllLegs).

FULL mode was checked and does not share the gap: DOUBLE::recSQRT_S_xmm widens
through ToDouble, which carries exponent 255 across exactly, and
EeRecFpuFull.SqrtPseudoInfExact already pins the true sqrt(2^128) = 0x5f800000.
Its inline note about the fast path was describing behavior the fast path did
not yet have; it does now, so the note is updated with the measured value.

Verified bidirectionally. On the unpatched emitter with these tests present,
SqrtClampsItsOperandLikeTheRestOfTheFamily fails on rows 44 and 45 in both clamp
modes (interp 5f7fffff vs jit 7f7fffff) and passes on row 46, and
EnginesAgreeExceptOnTheDocumentedRows fails because the two rows no longer
belong on the allowance list. SqrtClampCoversSignallingOperandsToo sweeps every
exponent-255 shape in both signs and fails on exactly the three signalling rows
under Fminnm. With the patch all enabled tests in the file pass, and the console
tally is unchanged at 20 match / 19 value-only / 3 flag-only / 15 both -- the JIT
moved onto the interpreter's answer without changing what the file says about
the hardware.

The tripwire is promoted to an enabled regression test that asserts the value as
well as the agreement -- agreement alone could be reached by degrading the
interpreter, which is the side nearer the console here.

Idea by pstef.
2026-08-02 22:24:24 -07:00
bmdhacks 2951dd5fb0 Tests: run the EE harness in the FP environment a game runs in
The recompiler suite ran at FPCR 0 -- round-to-nearest, denormals live
-- while a real game runs 0x1c00000, FZ plus ChopZero, from
EmuConfig.Cpu.FPUFPCR. So the suite was answering questions about an FP
environment no player has.

Production's model is already consistent and every engine implements its
half: ambient is FPUFPCR, the EE FPU DIV/SQRT emitters swap to
FPUDivFPCR and back, the microVU dispatcher loads VU0FPCR/VU1FPCR and
restores FPUFPCR, and the VU micro interpreters scope-guard to the same.
Only the harness never established the baseline the rest of that model
assumes -- EeRecTestHarness's own comment said as much, and chose to
contain each JIT block's FPCR mutation instead. VuTestHarness had
already worked around the consequence by pinning both of its passes to
the VU FPCR; ScopedEeFpcr is the EE-side equivalent, and it establishes
rather than contains.

mVU's skip-the-FPCR-load-when-equal gate is the sharpest illustration:
it compares FPUFPCR against VU0FPCR and skips when they match, which is
only sound if ambient really is FPUFPCR. It was not, so the VU micro JIT
ran at the host default while the VU micro interp applied VU0FPCR.

15 tests then failed, none of them from an engine disagreeing with the
other in the environment they were written for. One root cause covers
most: round-toward-zero saturates an overflow to +/-FLT_MAX, so nothing
is ever Inf, and every path that infers overflow from Inf is inert --
the VU O flag, the EE FPU "unclamped intermediate product" cases, and
FCR31's overflow bit alike. FZ accounts for the rest by erasing the
mantissa the VU U bit is defined over.

Rather than disable them, the environment becomes an explicit per-test
axis: ScopedFpEnv, which rewrites EmuConfig's four FPCRs for its scope
so the whole stack agrees -- poking only the host register would leave
the baked FPUFPCR immediate and mVU's sentinel disagreeing with it. Two
kinds, both states a user can actually configure: IeeeNearest for the VU
tests, which need denormals to exist, and FlushNearest -- bit-for-bit
the default FPUDivFPCR -- for the EE FPU tests, which need Inf but are
built around FZ and diverge between engines without it.

No coverage is lost. One test is repaired instead of tagged:
EmptyDestMaskRetiresTheMacFlag now raises S off a plain -1.0, because
its subject holds in every FP environment and an underflow witness tied
it to one. One is added: ProductionFpEnvironmentErasesUnderflowAndOverflow
pins what a game gets -- the engines agreeing, on a value the console
contradicts -- so nobody re-derives the U/O work from a green suite and
concludes it is reachable in play.

Two findings the old environment was hiding get DISABLED tripwires,
both confirmed to fail when force-enabled:

  - RSQRT_S is half-fixed. It rounds the sqrt to single but still
    divides in double, and double-rounding a quotient is benign often
    enough to vanish at nearest. Truncation is not so forgiving: at
    ChopZero the interpreter lands one ULP above the JIT again, the
    identical 0x3F5105EC/0x3F5105EB pair the original defect produced.
  - FCR31 misses overflow on BOTH engines in production, reading
    0x1000001 where the console says 0x1008011.

The second leaves a real question for hardware rather than for us: the
EE FPU truncates, so does silicon raise O from the magnitude of the
exact result, independently of rounding? A capture of FCR31 after an
overflowing ADD.S would settle it -- and the same answer decides the VU
O flag.

recompiler_tests 1517 pass / 0 fail / 22 disabled; core 86, common 31,
mvu_progcache 13, gs_vertex 21.

Idea by pstef.
2026-08-02 22:24:07 -07:00
bmdhacks 0d7e6df7fe Tests: FCR31 O and SO against hardware, as tripwires
pcsx2/FPU.cpp runs every EE FPU arithmetic op through
checkOverflow(result, FPUflagO|FPUflagSO): an infinite result saturates to
+/-fMax, raises O and the sticky SO, and returns early (so U keeps whatever it
had); a finite one clears O and then clears U. ABS/NEG/MAX/MIN clearFPUFlags(O|U).
DIV/SQRT/RSQRT pass 0 and must leave both alone.

The recompiler's fast path models none of it, so an overflowing MUL.S leaves
FCR31 reading a bare 0x01000001 where the interpreter -- and the console capture
in ps2autotests tests/cpu/ee_fpu/fcr.expected -- say 0x01008011.

Measured as a 27-row sweep over the whole class rather than the two rows the
capture happens to cover: 24 of 27 diverge between the engines, and the
interpreter matches the checkOverflow model on all 27. The three that already
agree are the DIV/SQRT/RSQRT negative controls, and they are live ones -- the
other 24 rows in the same table prove the probe can see an FCR31 change at all.
The harness gains DisableFpuOverflow/EnableFpuExtraOverflow so the clamp-mode
axis can be measured instead of assumed.

An emitter that closed all 24 was written and measured, then reverted, and five
tests go in DISABLED to record what it did not settle:

  DISABLED_EnginesAgreeExceptOnTheOverflowFlags
  DISABLED_EnginesAgreeOnOverflowFlagsAcrossTheArithmeticFamily
  DISABLED_OverflowFlagsComposeAcrossOneBlock
  DISABLED_ExceptionFlagsMatchConsole
  DISABLED_NanMathOverflowIsAnOperandClampModeDifference

fpuEmitOverflowFlags detected overflow as `fabs(result) > FLT_MAX` -- that is,
by sniffing for a HOST infinity, which makes an architectural flag a function of
eeRoundMode. Measured on this host: under the shipping ChopZero default it never
raises at all, and under eeRoundMode 1 or 2 it raises for ONE SIGN ONLY, because
directed rounding produces 0x7f7fffff on one side and 0xff800000 on the other.
It also fired on operations that are not overflows at all -- mul 2^128 by 1.0 or
0.5, add 2^128 + 0 -- contradicting the console on rows the JIT had got right.
And it cost +8 host instructions per arithmetic op (MUL.S 3 -> 11) for a flag
the x86 recompiler does not maintain at all -- every O/U write in
pcsx2/x86/iFPU.cpp is commented out.

The redesign should port iFPUd-arm64.cpp's ToPS2FPU_Full magnitude thresholds,
which are round-mode and FZ independent, rather than test for a host Inf. Direction
is unchanged: one correct engine against two, so the recompiler is the side that
moves -- making the interpreter stop raising O/SO would align all three cheaply
by destroying the only correct reference in the tree.

Two things deliberately left, both recorded rather than papered over:

- x86. pcsx2/x86/iFPU.cpp is in a separate CMake source list and is not built on
  this host, so the mirror could not even be compiled, let alone diffed against
  the interpreter. Its commented-out xAND lines are not the fix on their own
  either: they sit before the op and clear O|U unconditionally, which is only
  half of checkOverflow.
- The underflow half. checkUnderflow can only SET U from a denormal result, and
  every FP environment PCSX2 runs the EE under has FZ set, so the host flushes
  one to signed zero before either engine looks. With FZ off the engines also
  disagree on the VALUE, which is the denormal work item;
  DISABLED_UnderflowFlagsNeedFzOff pins it.

The fifth tripwire is a different question wearing the same clothes. "NAN math"
feeds ADD.S two raw exp-255 words, so the engines compute different things
before any flag logic runs -- interp clamps operands through fpuDouble and gets
Inf, the fast path gets a host NaN. Turn on CHECK_FPU_EXTRA_OVERFLOW and the row
aligns exactly, which is what
DISABLED_NanMathOverflowIsAnOperandClampModeDifference measures: it attributes
the row to the operand-clamp mode axis, a deliberate x86-JIT-parity compromise,
instead of leaving it as an unexplained entry in a known-divergence list. It
asserts FCR31 as part of that alignment, so it rides on the O/SO revert and is
disabled with the rest; the row itself stays in kFcrEngineDivergences either
way.

Idea by pstef.
2026-08-02 22:17:10 -07:00
bmdhacks bf4e1089a0 Tests: EE FPU overflow against hardware — the max is 0x7FFFFFFF, not FLT_MAX
ps2autotests' fpu/fcr.cpp has run MUL.S(0x7F7FFFFF, 0x7F7FFFFF) on hardware all
along, but prints the result with %f, so the only thing it ever recorded was the
string "NaN". This captures the bits: 57 EE FPU rows and 8 VU0 macro-mode rows
from a real PS2 over ps2link, every value a raw word.

One rule accounts for every row:

  The EE FPU's representable maximum is 0x7FFFFFFF == (2 - 2^-23) * 2^128.
  Exponent 255 is an ordinary exponent -- there is no Inf and no NaN. Overflow
  means exceeding THAT, it saturates there, and only then are O and SO raised.

So +FLT_MAX + +FLT_MAX is not an overflow on this machine: the exact sum is
representable and the console returns it with FCR31 untouched. 2^127 * 2 is
likewise fine; 2^127 * 4 is not. The generator asserts both halves of that in
exact rational arithmetic across all 47 arithmetic rows, plus an underflow law
(denormal operands flush to signed zero first, U follows from the flushed
result), and rejects a capture that fails either rather than reshaping it. Both
laws were confirmed live by corrupting the input. div 1.0/+0 is carried as a
known-answer control, and the run is byte-identical across two resets.

That max is one binade above what IEEE single can hold, which is why the fast
path cannot match the console here however the flag test is written -- the host
cannot represent the EE's top octave, so a result the console returns exactly
necessarily arrives as a host overflow. The FULL double path can, and does.

Also settles the VU half named in the same work item: VU0 saturates to
0x7FFFFFFF too and raises MAC O, and a row that overflows x, y and z while
leaving w in range confirms the x=8 y=4 z=2 w=1 nibble layout.

Nothing is "fixed" here. All three console divergences are shared by both
engines and deliberate -- 19 rows are the +/-FLT_MAX saturation compromise, 3
are underflow U|SU needing FZ off, 15 are the overflow pair -- so they are
recorded and left to the hardware-alignment stage.

What is not deliberate, and is what the capture surfaced: SQRT.S is the only op
in iFPU-arm64.cpp whose emitter never clamps its operand. fpuClampInput has
twelve call sites covering ADD/SUB/MUL/DIV/RSQRT and the six accumulator forms;
recSQRT_S_xmm calls it zero times, so an exponent-255 Ft reaches Fsqrt as a host
+Inf and comes back 0x7F7FFFFF where the interpreter lands two binades away.
Unlike the six operand-clamp rows beside it, this does not close under
CHECK_FPU_EXTRA_OVERFLOW, because there is no gate to turn on. The interpreter
is nearer the console on both rows, so the direction is to give SQRT the clamp
the rest of the family has. Recorded as a divergence with a DISABLED tripwire,
not fixed in this commit.

The engine-agreement test asserts the listed rows still diverge as well as that
the unlisted ones agree, so the allowance list cannot go stale silently.

kEngineDivergences does not list rows 3, 11 and 16 (mul 2^128 by 2.0, by 1.0,
add 2^128 + 0) even though they sit in the middle of the operand-clamp block
they look like they belong to. They are not divergences: both engines return the
same result word on all three, and the only thing that ever differed there was
FCR31, which is the O/SO question deferred to the redesign -- see the DISABLED
tripwires in ee_fpu_fcr_console_conformance_tests.cpp. The file says so in
place, so the omission cannot be read as an oversight.

Idea by pstef.
2026-08-02 22:15:32 -07:00
bmdhacks 3dba206e8a Fix: FULL-mode RSQRT returned -0.0 for the largest magnitude
ToPS2FPU_Full has an arm for values the EE's top binade can hold but a
host single cannot: halve the double, narrow, add 0x00800000 back to the
single. Its guard was |x| >= 2^129, inherited from x86 iFPUd.cpp's
dbl_ps2_overflow. But the largest number this FPU has is 0x7FFFFFFF ==
(2 - 2^-23) * 2^128, a whole binade below 2^129, so everything in the band
(kEeFpuMax, 2^129) was routed into the halving arm when it should have
saturated.

Halved, such a value sits just under 2^128. Under the divide unit's
round-to-NEAREST FPCR the narrow rounds it up to a host infinity and the
+0x00800000 carries out of the exponent field into the sign bit:

    0x7f800000 + 0x00800000 == 0x80000000

so the largest magnitude the FPU can produce came back as negative zero --
sign flipped and exponent field 0 rather than 255, which the NFS Carbon
corner (DivZeroOverZeroKeepsPseudoInfExponent) already established is
game-visible through guest softfloat classifiers.

Under the arithmetic FPCR the narrow chops to 0x7f7fffff and the arm is
correct, which is why only the ops that swap to FPUDivFPCR could reach it.
The interpreter's eeRoundToSingle is immune by construction -- it scales by
2^-4, and its comment says why: "the +4 lands on 255 exactly -- it can
never carry into the sign."

ONLY RSQRT REACHES THE BAND, which is why this survived. A DIV quotient
cannot: for 24-bit significands with a < b, a/b <= 1 - 2^-24 strictly, and
the band's relative width is exactly 2^-24. A sweep of the four reachable
exponent differences found 0 hits, and the first probe written for this --
DIV.S(0x7FFFFFFF, 0x3F7FFFFF) -- lands on 2^129 *exactly* and came back
correct, which is what sent me looking for the algebra. SQRT halves
exponents and cannot get near. RSQRT divides by a 53-bit sqrt result, so
the significand argument does not apply; a coarse sweep found 2.5M hits.

The fix is the bound, not the arm: compare against kEeFpuMax's double bit
pattern, with `hi` rather than `hs` because kEeFpuMax itself is
representable and the halving arm handles it exactly (halved it is
+FLT_MAX, and 0x7f7fffff + 0x00800000 == 0x7fffffff). Costs 2 extra
instructions to materialise the constant, on the cold toComplex arm; the
in-range path is untouched.

Verified bidirectionally: the new test's 5 pairs fail on the unpatched
source (jit 80000000, interp 7fffffff on all five) and pass after. The
liveness companion, DivKeepsTopBinadeResultsBelowTheEeMaximum, is green
both ways -- it holds 1.5*2^128 in the halving arm, so over-tightening the
guard down to 2^128 turns it red rather than letting the first test go
green for the wrong reason.

All six ctest binaries green on exit code: recompiler_tests 1640,
core_test 86, mvu_progcache_versioning_tests 13, gs_vertex_tests 21,
common_test 31, demangler_test. The 53 console-conformance and FULL-mode
tests (EeFpuOverflowConsole, EeFpuZeroDivisorConsole, EeRecFpuFull) pass
unchanged, so no capture row moved.

x86 iFPUd.cpp carries the identical constant (s_const DOUBLE(0, 1152, 0)
at :115, consumed at :185) and so has the same defect. Not touched here:
this is an aarch64 host, that column is never executed, and an unverifiable
port is not a fix.

Idea by pstef.
2026-08-02 22:14:35 -07:00
bmdhacks 9eb45cf590 Fix: the recompiler-test GIF Path 1 sink missed a wrapped XGKICK's head
`mVU_XGKICK_` splits a packet that runs past the top of VU1 memory in two:
the pre-wrap head goes to `Gif_Path::CopyGSPacketData`, and only the
post-wrap tail goes to `Gif_Unit::TransferGSPacketData`. The
PCSX2_RECOMPILER_TESTS sink hooked only the second one, so a wrapped kick
was captured as its tail alone — no GIFtag at all.

Measured while landing the console XGKICK cases: 64 captured bytes from the
recompiler against 112 from the interpreter, which loops through
TransferGSPacketData and never calls CopyGSPacketData. That reads exactly
like a serious microVU miscompile and is entirely our instrumentation; both
engines emit the same correct stream.

CopyGSPacketData now feeds the same sink and skips the ring, since nothing
drains it while the sink is installed. The gif_test_hooks declaration moves
above Gif_Path so the member function can see it.

Consequence, which is larger than the bug: before this, no test could
observe a wrapped XGKICK's GIFtag. Every XGKICK test used packets that fit
inside VU1 memory, so the blind spot never showed.

Test-build only — the whole block is inside #ifdef PCSX2_RECOMPILER_TESTS.

vu1_xgkick_drain_tests.cpp moves with it. That file is ours and postdates
the branch this came from, and its wrap case was written against the blind
spot: it asserted the JIT capture was the 32-byte tail, with a header
comment explaining that the head was unreachable. Both halves now arrive,
so it asserts the whole 48 bytes — head carrying the GIFtag, tail resuming
at offset 0 — which is the stronger property and the one that was never
testable before. The XgKickHack wrap case already asserted that shape and
only loses a stale "unlike the non-hack path" aside.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 63e7904e55 Fix: VU replay inherited the interpreter's stale sticky STATUS flags
vu_capture::CapturedState carries microVU's four-deep micro_statusflags[]
shadows but not the interpreter's live scalar accumulators
VU->statusflag/macflag/clipflag, and RestoreState left them alone. So the
interpreter pass of a replay started from whatever the previously executed
VU program had left in VURegs.

Most of that is harmless because it gets recomputed, but one field is not:

    _vuFMACAdd   snapshots VU->statusflag into fmac[i].statusflag
    _vuFMACflush ORs (fmac[i].statusflag & 0xFC0) into VI[REG_STATUS_FLAG]

0xFC0 is the STICKY field (ZS/SS/US/OS/IS/DS). Every op recomputes the 0xF
cause nibble, but nothing clears the sticky bits except FSSET -- so a stale
one rides straight through into the architectural result. The JIT derives its
status entirely from the restored micro_statusflags[] and never grows the
phantom bit, and the replay reports a divergence that belongs to neither
engine.

Seeded from VI[] rather than zeroed, because that is the exact inverse of the
flush above (VI[REG_MAC_FLAG] = fmac[i].macflag; STATUS takes the sticky field
plus the cause nibble). A capture taken with sticky flags already raised now
replays with them instead of silently losing them. No format bump -- VI[] is
already carried in full.

Found as an order-dependent failure of VuReplay.ReplayVu0Vadd... under
--gtest_shuffle. Diagnosed by execution, not by reading: with the triggering
predecessor in place VU0.statusflag was measured at 0x82 on entry to
ReplayCapture, and injecting that value directly reproduced the exact diff
`vi16: JIT=0x0 INTERP=0x80`. Injecting 0x02 (cause-only) did not -- only the
sticky half survives, which is the field the interpreter never recomputes.

Tests: the new ReplayDoesNotInheritStaleInterpreterStickyFlags seeds the
accumulator directly, so the fault is pinned without depending on a shuffle
seed; verified live (fails with the identical vi16 diff on the unpatched
RestoreState). ReplayVu0Vadd... also gained the diff_lines printout its VU1
twin already had -- a bare EXPECT_FALSE on `diverged` names no register, which
is most of why this took as long to triage as it did.

1538 pass, 0 fail. VuReplay no longer fails under any shuffle seed tried
(17, 4242, 99, 31337, 8, 2); EeRecCarbonSelfLoop.PinnedValueLoopCarriedBaseByteFill
still does and is unrelated.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 5bd3e7deb8 Fix: arm64 EATAN paired four coefficients with the wrong power of x
mVU_Globals (microVU_Misc.h) stores the EFU atan series in ascending power
order but names the entries T1, T5, T2, T3, T4, T6, T7, T8 -- that is, T5
is the x^3 coefficient and T2 the x^5 one. mVU_EATAN_arm called the helpers
in NAME order, so c5 landed on x^3, c7 on x^5, c9 on x^7 and c3 on x^9.
Only the first term and the last three were on the right power.

Against unknownbrackets/ps2autotests tests/vu/lower/efu.expected that is
worth up to 919642 ULP, worst on the largest reduced arguments since the
misplacement is in the low-order terms:

  case                    before      after   console
  EATAN CVF_3PI_OVER2     3fbc5441   3fae4be7  3fae4be7   919642 -> 0 ULP
  EATAN CVF_PI            3fa99875   3fa19dc5  3fa19dc4   522929 -> 1
  EATAN CVF_INCREASING    3fa72d09   3f9fe0ba  3f9fe0ba   478287 -> 0
  EATANxz CVF_DECREASING  3ee3f44a   3eed633d  3eed6339  -618223 -> 4
  EATAN CVF_MAX_MANTISSA  3f9012c8   3f8db70b  3f8db70b   154557 -> 0
  EATAN CVF_PI_OVER2      3f813895   3f807f4c  3f807f4c    47433 -> 0

Every EATAN-family row moved toward silicon or stood still; none moved
away. Clears bad_jit on eight cases (kEfuBadJit 126 -> 118) and drops
EATAN CVF_PI_OVER2 and EATAN CVF_PI from kEatanEngineDivergences, where
the JIT now agrees with the interpreter exactly. On the rows that still
differ the JIT is the side nearer the capture: it is exact where the
interpreter is 1-2 ULP out, because it evaluates in single precision
throughout while _vuCalculateEATAN goes through double-precision pow().

Free: the eight constants live within one struct at offsets 96..224, all
encoding as the same single scaled-immediate LDR, so the emitted sequence
is a permutation of identical instruction pairs -- same count, same bytes.

Upstream x86 mVU_EATAN_ has the identical defect and is left alone; it is
never executed on this host and is not updated upstream.

DISABLED_DumpEatanFamily is the measurement that produced the table, kept
so the reading can be re-made from data rather than from emitter source.
It was cross-checked against a standalone single-precision evaluation of
both orderings outside the emulator, which reproduces the same magnitudes
to within 3 ULP.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks fc5ca4aca4 Fix: interp EATAN's x^7 coefficient was T3 with a digit dropped
_vuCalculateEATAN's eatanconst[] is a hand-transcription of the atan
coefficients microVU keeps in mVU_Globals (microVU_Misc.h). Eight of the
nine round-trip to those globals bit for bit, and so do all five of the
sinconsts[] added alongside them. One did not:

    T3 printed:  -0.139085337519646
    in tree:     -0.13085337519646     <- the 9 after "0.13" is gone

Origin: 857ab07f1c (refractionpcsx2, 2021-09-06, "VUInt: Fix macro
flags and implement EFU ops correctly"), the commit that first gave the
interpreter a real EFU model -- before it, _vuEATAN was a one-line
atan() call behind a DevCon.Warning. The typo was there in that
function's first revision and has stood since; b0d1d4ff44 ("VU Int:
Clang formatting", six days later) reflowed the neighbouring lines but
left the literal untouched. It is upstream code, still present upstream
as of this tree's last sync, and several accuracy passes over VUops.cpp
have gone past it. T3 itself has been 0xBE0E6C63 in microVU since
04fba659014e (2009).

Silicon decides it. Interpreter values through the test harness against
the ps2autotests EFU capture, before and after:

  EATAN of fs.z     console    before     ulp    after     ulp
  1.0               3f490fda   3f490fdb     1    3f490fdb    1
  1.99999988        3f8db70b   3f8db72c    33    3f8db70c    1
  2.0               3f8db70b   3f8db72c    33    3f8db70c    1
  3.0               3f9fe0ba   3f9fe2d7   541    3f9fe0bb    1
  1.5707964         3f807f4c   3f807f4e     2    3f807f4c    0
  3.1415927         3fa19dc4   3fa1a070   684    3fa19dc5    1
  4.712389          3fae4be7   3fae591e  3383    3fae4be9    2

Across all 208 EFU cases, 17 interpreter values move, every one of them
closer to the console and none away; the recompiler's values are
untouched, as a control. The error grows as the reduced argument to the
seventh power, which is why it vanishes at Fs = 1.0 (where the reduction
gives exactly 0) and is worst at CVF_3PI_OVER2. EATAN(0) is the cleanest
signature: the interpreter returned 0xBC06DF00 = -0.0082362, which is
the coefficient delta itself.

EATAN CVF_PI_OVER2 now reproduces silicon exactly, so its bad_interp
flag and kEfuBadInterp go with it.

Verified both directions: the old source against the new expectations
fails with "new divergence from silicon", the new source against the old
expectations fails with "now MATCHES silicon", and together the suite is
1551/1551.

That measurement also refutes the reason vu1_efu_console_conformance_
tests.cpp gave for these rows -- double-vs-single precision drift "a few
ULP" wide from the interpreter's pow(). It was never that; the remaining
gap is hundreds of ULP and sits on the recompiler side. Comment
corrected rather than left to mislead the next reader.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 1d16981a29 Fix: interp EATAN family was missing the range-reduction argument transform
Deficient: interp. Correct: arm64 JIT + x86 JIT (both emit the same shape).

_vuCalculateEATAN evaluates the EFU's atan series and then adds
eatanconst[8] = 0.785398185 = pi/4. That constant is only correct as the
second half of

    atan(x) = pi/4 + atan((x - 1) / (x + 1))

so the polynomial has to be fed the REDUCED argument. Both recompilers do
that -- arm64 mVU_EATAN computes (Fs-1)/(Fs+1) before mVU_EATAN_arm, x86
mVU_EATAN the identical SUBSS/ADDSS/DIVSS -- and the interpreter passed the
raw argument, adding a pi/4 offset nothing had earned. The xy/xz forms want
the same identity for atan(y/x), i.e. (y-x)/(y+x), which is again what both
recompilers emit.

Re-derived arithmetically rather than read off the source: for Fs = 1.0 the
unreduced expression evaluates to 0x3FCA1D99, bit-for-bit what the
interpreter returned, and the reduced one to 0x3F490FDB, bit-for-bit what
both recompilers return (console 0x3F490FDA). A second witness at Fs = 3.0
agrees: unreduced gives 0xC6F69D96, i.e. -3e4 for an arctangent.

Dropping the `if (x != 0)` guard in the xy/xz forms is part of the same fix:
with the reduced argument the divisor is (y+x), not x, and the guard was
returning +0 where both recompilers and the console return a NaN pattern.

HOW THIS WAS INVISIBLE, which is the part worth keeping. Every test in
vu1_efu_console_conformance_tests.cpp scores each engine against silicon
SEPARATELY and records what it cannot reproduce per engine. That is
deliberate and the file says why: a pure JIT-vs-interp differential is blind
to anything both engines get wrong together. But it left the mirror-image
blind spot -- nothing asserted the two engines agree with EACH OTHER, and
CaseMatches() reduces each run to a bool and discards the value, so two
engines returning DIFFERENT wrong answers are flagged twice as "known bad"
and look settled. Measured: 111 of the 208 EFU cases had the engines
disagreeing. This fix brings 15 into agreement, leaving 96.

New tests, since that assertion class did not exist:
- EatanFamilyEnginesAgreeExceptWhereListed asserts engine agreement across
  all 48 EATAN-family cases, with the 33 still-diverging labels listed
  explicitly so movement in either direction fails loudly.
- EatanAppliesTheRangeReductionBeforeThePolynomial pins the defect as
  arithmetic (EATAN(1.0) is the bare pi/4 constant), so it stays meaningful
  even if both engines are later changed together.

The 33 remaining EATAN-family divergences are two further classes, both
documented in the test file and neither this commit's subject: the
recompilers hand raw exponent-255 patterns to the polynomial and get NaNs
where the interpreter clamps operands through vuDouble first, and the
interpreter evaluates the series in double-precision pow() against the
recompilers' single-precision Horner chain. On both, the interpreter is the
side nearer the console.

Bidirectional: with VUops.cpp reverted, both new tests fail (15 red
assertions naming jit=3f490fdb interp=3fca1d99). Suite 1530 pass / 0 fail /
22 disabled, plus core 86, common 31, gs_vertex 21, mvu_progcache 13.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 3d15e04c6c Fix: VU sticky bits accumulate where the flags are produced, not at flush
VU_STAT_UPDATE assigned the bare ZSUO cause nibble to statusflag, throwing
the sticky field away, and _vuFMACflush re-derived the stickies from that
cause nibble on every pipeline entry it retired. That is idempotent right
up until an FSSET clears the sticky field: the stale cause then
regenerates the bit FSSET just cleared.

Traced on VUSTICKY_MICRO_FSSET_ASSIGNS_NOT_ORS. The FSSET merge itself is
correct -- it produces VI=801 -- and the bit comes back one entry later:

  [FSSET]      imm=800 sf 001 -> 801
  [FLUSH] pipe[2] flagreg=10000 entry.sf=801 VI=041 -> VI=801 (FSSET arm)
  [ADD]   pipe[3] lower VIwrite=8 snap.sf=801
  [FLUSH] pipe[3] flagreg=8     entry.sf=801 VI=801 -> VI=841 (FMAC arm)

pipe[3] is not an FMAC at all; flagreg=8 is a lower op writing integer
register VI[3]. It took the non-FSSET arm only because every retired entry
rewrites STATUS, and (sf & 0xF) << 6 turned the long-dead Z cause back
into sticky Z.

So the sticky OR moves to VU_STAT_UPDATE, where the flags are produced,
and both flush sites take the sticky field from the snapshot instead of
re-deriving it. statusflag is seeded from the whole STATUS register in
vu0ExecMicro, so carrying the sticky field in it is the documented intent
-- VU_STAT_UPDATE's original comment claimed exactly this preservation
while the code did the opposite.

Both halves are load-bearing: reverting VU_STAT_UPDATE alone fails the
Vu0AluUpper suite, reverting the flush arms alone leaves the FSSET rows
diverging.

Graduates VUSTICKY_MICRO_FSSET_CLEARS and
VUSTICKY_MICRO_FSSET_ASSIGNS_NOT_ORS from kMicroDivergences, which no
longer holds any interp-only row.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 4a7284ea4a Fix: ESQRT/ERSQRT take the operand's magnitude
The EFU square root roots a negative operand as if it were positive; the
interpreter's `p >= 0` guard returned the operand unchanged instead, so
ESQRT and ERSQRT of -1.0 gave -1.0 where the console gives 1.0, and
ESQRT of -0.0 gave -0.0 where the console gives +0.0.

Both recompilers already AND the raw bits with absclip before FSQRT
(mVU_ESQRT, mVU_ERSQRT), so masking the sign off before vuDouble puts the
interpreter on the same order of operations rather than on a new one.

Clears bad_interp on ERSQRT CVF_NEGONE, ESQRT CVF_NEGZERO and
ESQRT CVF_NEGONE; kEfuBadInterp 119 -> 116.

The remaining negative-operand rows (CVF_MIN, CVF_GARBAGE2, ...) still
fail on BOTH engines and are untouched here: they are Inf/NaN inputs that
vuDouble only clamps to fMax under CHECK_VU_OVERFLOW, which ships off.
That is a separate, all-engines-wrong defect.

Idea by pstef.
2026-08-02 17:03:02 -07:00