Commit Graph
100 Commits
Author SHA1 Message Date
pstef 46e75bdc94 GameIndex: record game serials that can benefit from mode 3
DJbox                                      2
Ratchet & Clank 2                         13
Tourist Trophy                             6
Monster Hunter                            12
The Taxi 2                                 1
Pride FC                                   4
Jak and Daxter - The Precursor Legacy     14
Jak II                                    10
Jak 3                                      6
Final Fantasy X                           15
Driv3r                                     6
Shadow of the Colossus                     5
Gran Turismo 4                            19
Tales of the Abyss                         4
Disney/Pixar Up                            5
Need for Speed - Undercover                9
Mortal Kombat - Shaolin Monks              3
Tokyo Xtreme Racer Zero                    5
Yu-Gi-Oh! - The Duelists of the Roses      3
Devil May Cry 3 Special Edition            5
Constantine                                3
Sega Ages 2500 Virtual On                  1
Tales of Destiny - Director's Cut          4
Colin McRae Rally 3                        2
Stuntman                                   5
Deadly Strike                              1
Virtua Fighter 4                           4
R:Racing Evolution                         5
Xenosaga Episode III                       7
Steambot Chronicles                       11
Fatal Frame                               10
Playmobil - Hype - The Time Quest          3
2026-08-16 17:41:25 +02:00
pstef 5598420cdb Tests: pin the COP2 macro MADD/MSUB clamp sets against the interpreter
Exponent-0xFF operands through VMADDw and VMSUB at every dest mask, one
case per half of MADDw's set. MADDx/y/z runs the same shape with its
broadcast lanes in range as a scope control.
2026-08-16 17:02:58 +02:00
pstef 35b239f4c4 Fix: COP2 macro MADDw and MSUB clamp the operands microVU names
arm64 hand-rolls these ops instead of going through the mVU emitter, so
microVU's per-op clamp argument is retyped in iCOP2-arm64.cpp. The two
rows conditional on isCOP2, MADDw's cACC|cFt|cFs and MSUB's cFs, were
missing.

Shadow of Destiny moved its player character a fraction of a step per
frame under the recompiler, at every eeClampMode, on the MADDw row.

Clamping ACC also changes results against the interpreter for values
above FLT_MAX, but is what the x86 COP2 path already does.
2026-08-16 17:02:54 +02:00
pstef d17b095b1f EE/UI: put eeClampMode 4 on the clamping mode picker
Exact was reachable through the GameDB and the INI only: the desktop and
Big Picture pickers stopped at Full, and cleared fpuExactMode behind
themselves. Both now read and write it with the other three bits, so the
mode can be picked and a config already on it displays as itself.

The Big Picture choice list is sized from its own array. It had taken
the EE array's length for the VU pickers too, which held only while the
two arrays were the same length.
2026-08-16 15:20:58 +02:00
pstef 85fb137bbc EE/FPU: name the clamp modes the slot format actually follows
eeFprSyncSlotFormat keys on CHECK_FPU_FULL, so the relocated file is
what both of iFPUd's rungs compute over, not mode 3 alone. The comments
that name a mode where they mean the tier are renamed to the tier.
2026-08-16 00:11:47 +02:00
pstef 8ee895a649 EE/FPU: divide by the unit's own recurrence at eeClampMode 4
DIV.S, SQRT.S and RSQRT.S ran on host doubles on every recompiler rung,
which makes them correctly rounded; the EE's divide/square-root unit is
a digit recurrence with no rounding step and is not. The interpreter has
run the recurrence since the model landed, so mode 4 now calls the same
two functions out of line rather than reproducing them. eeDivide and
eeSqrtBits stop being static for it.

RSQRT.S composes the two with an ordinary single in between, as silicon
does, and its intermediate crosses the square root's call through the
island's scratch. Only the value moves: the negative-operand and
zero-divisor arms, and every flag they set, are the code they were.

Modes 1 to 3 keep the host instruction and the FPUDivFPCR swap.

The rsqrt sign table gains a mode-4 leg, which owes every row; the
filter for the twenty rows mode 3 misses by one ULP stays where it is,
now as that mode's own pin. The divide unit's console table gains a
mode-4 leg beside its interpreter one.
2026-08-16 00:11:47 +02:00
pstef f7aafd62c4 EE/FPU: lift the call island's frame out of the multiply
The multiply array's out-of-line call carries its own spill of the
allocator's live caller-saved homes and its own pin flush/reload pair.
The divide unit needs the same frame around calls of its own, so the two
halves become emitIslandEnter/emitIslandLeave and the multiply is
rewritten on top of them.

Enter takes a byte count for scratch above the saved registers, which
nothing needs yet at zero and RSQRT.S will: its dividend has to outlive
the square root's call.
2026-08-16 00:11:46 +02:00
pstef ac9ca7c2b7 EE/FPU: pin where O comes from against the console
A standalone probe of the exponent band the FP matrix corpus does not
reach: a large operand at exponent 255 against an addend walked across
the guard mask's erasure boundary and the narrowing boundary just above
it, through all eight add/sub-family forms. Both engines and both rungs
of the DOUBLE path are scored separately, and the table is checked for
rows of each polarity so an engine that never raises O cannot pass it.
2026-08-16 00:11:46 +02:00
pstef 4c557972d1 EE/FPU: flag the adder's own sum, not a second one
raiseOrClearOU() was handed a sum recomputed from the unmasked operands
while the destination got the masked one, so an operand the guard mask
had erased could still move O. The add/sub family now rounds and flags a
single value, eeGuardedSum()'s, and the accumulates hand their second
raise the sum eeMulAccumulate() actually formed instead of rebuilding it
from the ACC and the unrounded product.

The overflow test moves with it, into eeRoundsOutOfRange(), so the value
path and the flag path agree about the band where a result narrows back
onto kEeFpuMax.

madFlushedProduct() goes with the rebuild it existed for: the product
reaches the adder through eeMulRound(), which already returns a signed
zero for an underflowing one.
2026-08-16 00:11:46 +02:00
pstef d41120d16e EE/FPU: decide saturation after the narrowing, not before it
Both narrowing helpers compared the wide result against kEeFpuMax and
saturated on anything above it. A result can be above it and still
narrow back onto it, and then nothing saturated and O must stay clear;
ADD/SUB and the accumulates reach that band whenever the guard mask
leaves the smaller operand its leading bit and nothing else. Masking the
low 29 mantissa bits before the comparison is that narrowing, under the
arithmetic FPCR.  DIV/SQRT/RSQRT round to nearest and pass flags=false,
so their test is unchanged.

The mask sits on the arm that has already branched away for 2^128 and
up, so the in-range path is untouched.
2026-08-16 00:11:46 +02:00
pstef 366308d53c EE/FPU: guard-mask ADD/SUB's operands in the wide domain
The mask keys on the exponent difference and clears low mantissa bits,
and a widened slot carries both: the exponent field moves from bits
23..30 to 52..62 and mantissa bit k to bit k+29, so FPU_ADD_SUB_D --
already the form the accumulates use -- is the same law with the shift
29 further left. That leaves nothing wanting the architectural single,
so both operands go straight through SlotToDouble, and the narrowing
pair and the widening's exponent-0xff test go with the single-domain
form. ADD.S and SUB.S drop from 127 host instructions to 93, ADDA.S and
SUBA.S from 132 to 98.

recFPUOp was the only caller reaching the arm that masks ft, which the
console corpus never sampled, so the new witness table covers it against
the interpreter and the console rows now also run through the DOUBLE
path.
2026-08-16 00:11:46 +02:00
pstef a0eaa92c0a EE/FPU: narrow SQRT's root with a plain Fcvt
ToPS2FPU_Full's saturating and flushing arms are for results that leave
the PS2's range, and a root does not, so both sat dead behind their
compares. The body goes from 54 host instructions to 23, under the 26
the single-precision path spends.
2026-08-16 00:11:46 +02:00
pstef deda4c2bb2 EE/FPU: take RSQRT's operands from the slots
The same three moves as DIV: the sign test reads bit 63, Fcmp against
zero reads the slot, and the saturated result assembles from
kEeFprSignBit and kEeFprMaxBits. |ft| becomes unconditional, since it is
a no-op on the positive arm and is also the copy that keeps the guest
slot intact — which is what narrowSrc was there for.

108 host instructions to 75.

narrowSrc, ToDouble and ToDoubleFrom are down to one caller between
them: the ADD/SUB guard mask, which rewrites the word.
2026-08-16 00:11:46 +02:00
pstef 5231effec7 EE/FPU: divide from the slots, and assemble the zero-divisor result as one
recDIVhelper1 narrowed both operands so it could compare them against
zero and xor their signs in the word domain. Fcmp against zero reads the
slot the same way — FZ flushes an EE denormal there too — and the sign
sits at bit 63, so the saturated result assembles from kEeFprSignBit and
kEeFprMaxBits without a word ever being formed. Both arms leave a slot
now, which costs the caller a register move where it paid for a widen.

102 host instructions to 69, and four temp NEON registers to two.
2026-08-16 00:11:46 +02:00
pstef 17a3ab556e EE/FPU: widen SQRT's operand straight from its slot
The body narrowed ft to the architectural single, tested the sign bit
there, then widened that single back to a double through ToDouble's
exponent-0xff branch. The slot carries the sign at bit 63 and holds the
value scaled by a power of two, so the test reads the slot as it stands
and the widening is SlotToDouble's one exact multiply.

71 host instructions to 54.
2026-08-16 00:11:46 +02:00
pstef b8380ff0c0 EE/FPU: select MAX/MIN in the slot domain
The double tier narrowed both slots to words, wrapped each in a 64-bit
double pattern, ordered them with Fmax/Fmin and widened the winner back.
The relocation is order-preserving, so the ordering key the fast path
already uses reads the slot unchanged, and Csel picks between the two
untouched registers.

24 host instructions to 14, and two temp NEON registers to none. What is
left over the fast path's 12 is the fprc[31] memory RMW this tier owes.
2026-08-16 00:11:46 +02:00
pstef 7b133ae02f EE/FPU: pin the multiplier deficit away from exponent 127
Both capture-backed tables read the operands the fpmul3 sweep produced,
which is exponent 127 with both operands positive. Every model of the
deficit here reads the significands and nothing else, and nothing said
so.

Sixteen rows from a probe that moves the other two fields: four sign
combinations across five exponent placements for eight separating
operand pairs, of which the extremes are kept. All 160 rows the probe
ran came back one ULP low and no operand pair changed its verdict.
2026-08-16 00:11:46 +02:00
pstef 9c05af0901 EE/FPU: pin both clamp modes' multiplies against the console
The two rungs differ on two classes of operand and the 8137-case
hardware corpus reaches neither: its zero-tail multiplies all carry ft
mantissas the boundary term cannot read, and its rows inside the array's
band either saturate or come back exact. It scores mode 3 and mode 4 the
same, so nothing in the tree said which of them silicon agrees with.

The fpmul3 capture does. It swept eight fs significands against every
one of the 2^23 ft significands on an SCPH-90000, recording for each
whether the console returned the correctly-rounded product or that
product one ULP low, and those two are the only outcomes it ever saw.
Twenty rows where the boundary term decides and twelve inside the band
are transcribed here with both values, so each asserts mode 4 against
silicon and mode 3 against the rounding it is supposed to keep.

Half of each table has the two values equal, which is the polarity that
stops an emitter that decremented every product from passing.
2026-08-16 00:11:46 +02:00
pstef b0ddecfede EE/UI: Remove the FPU multiply gamefix
It patched one product, 0.25 * pi, from the correctly-rounded 0x3f490fdb
to the 0x3f490fda the EE's multiplier returns, so Tales of Destiny stops
hanging. That product is one sample of the multiplier's one-ULP deficit,
and eeClampMode 3 models the deficit for every operand pair -- including
the asymmetry the gamefix reproduced by comparing fs and ft against
their own constants, which falls out of a predicate that reads ft alone.
The seven serials carry the clamp mode instead, and iFPU-arm64.cpp's
multiply is a bare Fmul again.

The toggle goes from the Qt, FullscreenUI, Android and iOS front ends,
along with the GameDB schema enum and the harness helper that set it.

Fix_FpuMultiply and the config bit stay: vu_capture's on-disk gamefix
mask is bit-indexed by GamefixId, so dropping ordinal 0 would
reinterpret every capture already recorded. tbl_GamefixNames keeps the
name, so a GameDB that still lists it parses and does nothing.
2026-08-16 00:11:21 +02:00
pstef 94c4fa72ea EE/FPU: split iFPUd's multiplier deficit into a fourth clamp mode
Both iFPUd modes emitted the multiplier deficit in full: the Booth term,
the boundary predicate over it, and an out-of-line call to the multiply
array for what neither decides. eeClampMode 3 now emits the Booth term
alone, three instructions off ft's mantissa; a new eeClampMode 4 keeps
the other two.  Nothing else differs between them.

Mode 4 reaches the config through the GameDB and the INI and has no
picker entry, but the front ends still write the bit: ApplySanityCheck
rejects a config whose bits are not a whole mode, and a rejected config
falls back to the default rather than to the mode that was picked.

The harness's clamp-mode helpers set whole modes for the same reason.
2026-08-15 23:57:53 +02:00
pstef 063c7290df GameDB: drop FpuNegDivHack from the documented example
No such gamefix exists; the loader reports it and drops it.
2026-08-15 23:57:48 +02:00
pstef abd6a73bc2 EE/FPU: call the multiply array for the rest of the deficit
A product whose tail is non-zero but smaller than the array's 2^15
borrow still loses an ULP, and ft alone does not say which of those it
is; only reconstructing the truncated low columns does. Mode 3 guards
for that band and calls eeMulOneUlpLow, which the interpreter already
reaches inline.

The call is plain AAPCS from inside a block, and the only one this file
emits, so it carries an island: the allocator's live caller-saved homes
are spilled around it and the EE pin mirrors go through their
flush/reload pair.
2026-08-15 23:57:43 +02:00
pstef 03da2331ae Tests: give an EE snapshot the FPR format it was captured in
A slot does not say which format it holds, and the global that does
moves with eeClampMode, so a snapshot decoded under a mode other than
the one it was taken in returns a different number for every non-zero
word. Two harnesses alive at once is enough to reach it: an interpreter
leg read after a mode-3 leg has run.

The format now travels with the bytes, the FPRs are read through the
snapshot rather than through FPRreg::Word(), and a restore re-encodes
them for the file it is going into.
2026-08-15 23:57:35 +02:00
pstef 7b8e56a144 EE/FPU: give mode 3 the multiplier predicate's boundary term
The predicate was a Cmtst against a mask parked in d10, which can
express the Booth term and nothing else, so iFPUd came back one ULP high
wherever the boundary term at the truncation column was the deciding one
-- a class the interpreter models. Building the predicate in GPRs
instead admits both terms, and turns the old shape's implicit tail test,
which worked only because FPUFPCR holds round-toward-zero, into an
explicit one.

Nothing reads d10 after that, so it leaves the reserved list and the
allocator has its callee-saved slot back.
2026-08-15 23:57:30 +02:00
pstef 8542a9c38a EE/FPU: compute mode 3 in the relocated domain
iFPUd widened every operand with ToDoubleFrom: eleven instructions
branching on exponent field 0xff, since a PS2 single with that exponent
is an ordinary large number IEEE reads as Inf or NaN. Against a
relocated slot the widening is one Fmul, and a compare goes from 34.75
host instructions to 8.75. CVT.W loses its NaN fix-up the same way,
leaving Fcvtzs.

Which format the file is in follows the clamp mode, so modes 0-2 keep
the architectural word in the low half. FPRreg's accessors read the
format and the emitters read the mode; the two engines meet at the
boundaries the word is still observable at -- MFC1, MTC1, MOV.S, the two
conversions, LWC1, SWC1 and the allocator's fill and spill.

The unscale factor takes q11 and d10's mask moves to the relocated
layout, so the allocator's callee-saved range is q12-q15.
2026-08-15 23:57:24 +02:00
pstef c5183278e5 EE/arm64: measure the callee-saved NEON budget
The header called the range's size the minimum because a 3-operand MMI
op needs three homes at once. The floor is three; the slots above it
keep an FPU multiply-accumulate's four FPR homes inside the
call-surviving range.

EeFuzz.CalleeSavedNeonBudget reads high-water marks of the per-op needed
set from a test-build probe in _clearNeededNEONregs.
2026-08-15 23:57:20 +02:00
pstef 39319f35b4 EE/FPU: read and write an FPR through an accessor
The slot is 64 bits and the architectural register is 32, so a word view
of the union is only right while the two coincide. Word() and SetWord()
replace f/UL/SL and are the identity, so nothing that runs changes; what
it buys is that the compiler names every place that reads a slot as a
word, which is the set the next commit has to relocate.

The x86 tier is not built here and still reads the members directly.
2026-08-15 23:57:13 +02:00
pstef 8b0494b51f EE/FPU: add the FPR word <-> host double relocation
EeFpuFormat.h defines the format and AsmHelpers.h emits it. Widening a
slot already held in it is one Fmul against a parked 2^896, where mode
3's ToDoubleFrom branches on exponent field 0xFF. Nothing computes in it
yet.

Arm64JitBuffer.h is arm64_emit_test.cpp's MAP_JIT buffer, unchanged,
moved out so the relocation's emit tests can call the code they emit
too.
2026-08-15 23:57:09 +02:00
pstef ce6484d947 EE/FPU: widen the FPR file's slots to 64 bits
The word stays in the slot's low half. Nothing but the stride moves.

fpuRegisters was itself the savestate's wire form; fpuRegistersWire is
now, and the freeze boundary converts.
2026-08-15 23:57:03 +02:00
pstef bbabb478e5 Tests: cover mVU_FMACd in the clamp-mode digest probe
The clampE probe compiled MADDA then MADD, which leaves mVU_FMACd - the
third emitter body the abi 17 clamp change touched - pinned by nothing.
A second two-op program covers it, and mVU_FMACb's other opType with it.
2026-08-09 11:24:26 +02:00
pstef a521b590c4 EE: drop QFSRV's adjacent-source fast path
Rs == Rt+1 puts the two sources next to each other in the GPR array, so
a 16-byte read at &GPR.r[Rt] + sa funnelled them without a TBL. It was
the slower of the two sequences on both cores at every sa, and worst on
the twelve adjacent pairs that have a pinned source - the register range
a funnel-shift memcpy loop uses.

armFlushEEGPRPin had no other caller.

The sa mask stays, but it no longer guards a host address. Every writer
of SA - MTSA, MTSAB, MTSAH - already keeps four bits, so the two
oversized-sa tests could not fail; one goes with the path it named and
the other says what it covers.
2026-08-09 11:21:33 +02:00
pstef 20a4fc98d6 Tests: pin the VU div unit against the console
502 VU0 macro-mode cases of DIV, SQRT and RSQRT over zero, denormal,
normal, exponent-255 and saturated operands, scored on all four engines.
The three commits before this one each fixed one thing an engine had
wrong; this holds the whole grid rather than the operands that happened
to expose them.

DIV earns its place by separating the two rules those commits turned on:
its Invalid never comes from a sign, and its saturated quotient takes
the xor RSQRT's cannot. An RSQRT-only table would fit either rule.

Q is scored by class rather than by row, with exact per-engine tallies.
The arithmetic gap under it is a separate piece of work, and this is
where it gets a number to move.

vu_rsqrt_divisor_sign_tests states the same rules on hand-picked
witnesses, where they can be read instead of counted.
2026-08-09 11:21:33 +02:00
pstef bb3402401f VU: RSQRT's zero over zero raised both causes and returned a zero
_vuRSQRT and recCOP2_VRSQRT took divide-by-zero from the divisor and
then added Invalid when the dividend was zero too, so 0/0 came back with
both standing. DIV has never done that on either engine, and mVU_RSQRT
does not either: the causes are exclusive, and the console gives Invalid
alone.

The quotient goes with it. 0/0 was the one zero divisor answered with a
signed zero rather than the saturation value, which is another place the
two modes disagreed.
2026-08-09 11:21:33 +02:00
pstef 7413241b9f VU: SQRT's Invalid missed a negative zero the same way RSQRT's did
_vuSQRT and recCOP2_VSQRT decide it with an ordered compare against
zero, which -0 passes; the negative denormals reach them already flushed
to -0 and pass too. mVU_SQRT tested the sign bit and was the only one
raising Invalid for either. The console raises it for all of them.

The compare had a second effect on the macro side, since an unordered
compare is not "greater or equal": a positive operand with exponent
field 255 raised Invalid there and nowhere else.
2026-08-09 11:21:33 +02:00
pstef cecdf52737 VU: RSQRT lost the square root's Invalid on a negative zero divisor
Three engines, three routes to the same missing bit. _vuRSQRT and
recCOP2_VRSQRT returned from the zero branch before the divisor's sign
was ever tested; mVU_RSQRT did test it first, then assigned the zero
branch's divide-by-zero over the Invalid it had just set.

The saturated quotient's sign went the same way. _vuRSQRT and
recCOP2_VRSQRT xor the operand signs as DIV does and mVU_RSQRT does not,
so the two modes disagreed on every negative zero divisor.

vu_sticky_console_conformance carried four rows recording the flag half
as a known divergence against its capture; they go. mVU_RSQRT changing
shape bumps kMvuCompilerAbiVersion, which evicts every on-disk VU
program cache.
2026-08-09 11:21:33 +02:00
pstef 61c46529dd Tests: pin RSQRT.S against the console's negative zero divisors
A divisor that is negative and has a zero exponent field is the only
operand class in which the EE's two divide-unit causes can co-occur, and
the corpus has no row in it. A probe put 772 cases on an SCPH-90000; the
table is that capture.

It runs the same operands as sqrt.s, as div.s and as the two-instruction
sequence, so the composition the flag rule follows is asserted rather
than described. Each tier's divergence is asserted as itself: mode 3's
one ULP on a top-binade divisor, the fast path's saturation, and the
top-binade operands it hands to ee_fpu_top_binade_console_tests.cpp.
2026-08-09 11:21:33 +02:00
pstef 72feaa20ea EE: RSQRT.S lost the square root's Invalid on a negative zero divisor
The divisor's exponent field decided the whole instruction: the zero arm
returned before the sign test, so -0 and the negative denormals came
back carrying the division's cause alone. Hoisted, on the interpreter
and on the fast path. fpuFullMode already tested the sign first and is
untouched.

DenormalDivisorTreatedAsZero asserted the shape the other two shared.
2026-08-09 11:21:33 +02:00
pstef a0fe217021 Tests: pin RSQRT.S against the console's exponent-255 divisors
The file's divisor coverage stopped at fMax, so the words the host reads
as an infinity or a NaN had no rows at all.

The second test needs no console column: RSQRT.S and a SQRT.S followed
by a DIV.S round the same root the same way, so they have to agree
wherever the prescale applies.
2026-08-09 11:21:32 +02:00
pstef e9fba7474c EE: RSQRT.S divided by the host's reading of the top binade
SQRT.S got a prescale for exponent-255 operands when its own emitter was
fixed. RSQRT.S has a square root of its own inline and never got one, so
it was the last place on the fast path handing those words to Fsqrt.
2026-08-09 11:21:32 +02:00
pstef 7cf9705f21 Tests: run RSQRT.S behind a CTC1
Every FCR31 assertion in the file put the op alone in its block, which
is the one shape where there is no resident FCR31 to write back over it.
The new rows seed the same four flag states through a CTC1 instead, and
keep the memory-seeded leg beside each one so the pair fails
asymmetrically.
2026-08-09 11:21:32 +02:00
pstef 3076966a26 EE: RSQRT.S's cause bits were lost behind a resident FCR31
The rest of the COP1 family reaches fprc[31] through fpuTryAllocFCR31,
so a CTC1 or a compare anywhere earlier in the block leaves the register
living in a GPR until the block ends. RSQRT.S kept its own
load/modify/store against memory, and the slot's writeback at the seam
put the old word back on top of it: the clear of I|D and the raise of
I|SI or D|SD were both dropped whenever something in front of the op had
made FCR31 resident. It was the last emitter on the fast path still
going to memory.
2026-08-09 11:21:25 +02:00
pstef 5b872bfccb Tests: pin the zero divisor's cause bit to the dividend
The file carried the console's result word for its ten rows and nothing
about FCR31, which is the axis the two preceding commits move. The rows
gain a flags column, the denormal dividend gets two DIV rows of its own,
and DIV.S's clear gets the two pre-state rows that show it.
2026-08-09 11:21:25 +02:00
pstef 9bab278143 EE: DIV.S left an earlier instruction's I and D standing
Of the three interpreter ops that raise those two causes, DIV.S was the
only one that did not clear them first.
2026-08-09 11:21:25 +02:00
pstef 38e1611862 EE: RSQRT.S raised D on every zero divisor
It never looked at the dividend, which is what decides the cause.

Both single-precision tiers move together: the differential fuzzer in
ee_rec_fpu_rsqrt_tests.cpp diffs the interpreter against the fast path
on exactly this operand class, so neither passes alone.
2026-08-09 11:21:25 +02:00
pstef a6a760f194 Tests: point the guard-bit comments at eeGuardedAddSub
fpuAddSubGuarded is not a name in the tree. The interpreter's adder is
eeGuardedAddSub; fpuGuardedAddSub is the INI bool the recompilers gate
on.
2026-08-09 11:21:25 +02:00
pstef a9269afefb Tests: draw the guard-bit operand streams by hand
std::uniform_int_distribution is not specified down to the bit, so the
libc++ CI ran different operands from the libstdc++ one and a failure
there could not be reproduced here. Building an operand in a single |
expression added a second source of drift, its sub-expressions being
unsequenced.
2026-08-09 11:21:25 +02:00
pstef 152b7a9384 Tests: the guarded add/sub skip could not fire
The randomized test skipped pairs whose result leaves the EE's range by
testing the model's output for an exponent of 255. The model computes in
host singles under chop rounding, which saturates rather than
overflowing, so the skip never fired: a pair whose exact difference sits
above FLT_MAX was asserted against a value the model cannot hold, and
the interpreter's top-binade answer read as a failure.

The skip now reads the saturated word, and the pair that found it is a
row of its own.
2026-08-09 11:21:25 +02:00
pstef 0a0633e551 EE: the multiply predicate is one instruction, not three
Parking the mask in d10 removed the Mov/Fmov that materialised it. The
boundary term's instruction-count comparison, a few lines below, was
never updated to match.
2026-08-09 11:21:25 +02:00
pstef 9f449b6dd2 Tests: give the digest table a probe that compiles under vuClampMode:2
Every existing probe compiles at the default clamp mode, where mVUclamp3
and mVUclamp4 emit nothing, so the two-step FMAC change at abi 17 moved
none of the eight existing digests.

maddClampE compiles MADDA then MADD through a new helper that switches
on vuClampMode:2. What it covers, and why two ops rather than three, is
at the call site.
2026-08-09 11:21:25 +02:00
pstef 52d5e80562 mVU: stop re-clamping the product in two-step FMACs
MADD, MSUB and the A-forms emit a multiply step and an accumulate step.
The multiply step ends with mVUclamp4 on the product, and the accumulate
step opens with mVUclamp3 on the same register. Under vuClampMode:2 that
is one dead clamp in the six the pair emits.

The accumulate step now takes a mask naming the operand its caller
already clamped. The two steps are adjacent in one emitter body, so
nothing can clobber the product between them.

kMvuCompilerAbiVersion 16 -> 17, so on-disk caches holding the old shape
evict.
2026-08-09 11:21:25 +02:00
pstef 7efc687e41 Tests: pin the ABI-17 digest row to the emitter, not to a fix that never landed
The row and the divUnit probe arrived together in a previous commit, but
the change they describe - writeQreg flushing a denormal quotient to
signed zero - is not in the tree. writeQreg is still a bare Ins and both
DISABLED_*DivUnitFlushesDenormalQToSignedZero tripwires still fail when
run, so the row pinned a shape this repo has never emitted.

Nothing selects row 17 while kMvuCompilerAbiVersion is 16, so the
mismatch was invisible until something bumped, and then it surfaced as a
divUnit drift against whatever unrelated change did the bumping.

Repin it to what the emitter produces. divUnit becomes a live check for
the first time either way: every row at 16 and below carries 0 there,
which the guard skips.
2026-08-09 11:21:20 +02:00
pstef 3de8f14266 EE: hoist an independent JR/JALR delay slot ahead of the jump
recJR/recJALR were the only x86 TrySwapDelaySlot callers the arm64 port
did not carry over, though every EE branch here already goes through the
same table. A slot the table clears now runs before the jump instead of
between the target capture and the block tail.

EeRecJump's two SwappableDelaySlotHoisted tests already described this
behaviour; on arm64 nothing had been doing it.
2026-08-09 11:20:53 +02:00
pstef df1e072706 EE: hold the JR/JALR target in a register across the delay slot
recJR/recJALR stored the jump target to cpuRegs.pcWriteback before the
delay slot and SetBranchReg loaded it straight back, so every guest
return and indirect call went through memory. ARM64TYPE_PCWRITEBACK and
its writeback case were already in the allocator for exactly this and
had no users: the port kept the support and dropped the allocation that
x86's recJR/recJALR make.

Park the target in that slot, and have SetBranchReg read back whichever
of the register or its memory spill ends up holding it.

The added tests cover the spill arm. Reaching it takes a delay slot
carrying an interpreter seam, so nothing else in the suite goes there.
2026-08-09 11:20:53 +02:00
pstef 3daae8a4bb Optimization: funnel the quad shift through TBL instead of memory
QFSRV used the same store-and-reload trick x86 needs for a
cross-register byte shift. A two-register TBL indexed by a byte ramp
plus a broadcast sa does the same shift without the reload - recQFSRV's
comment has why the reload, not the spill, was the expensive part.

The ramp lives in the register pack next to the COP2 constants; R5900.h
explains why.

The two AX-03 tests keep their assertions and change their names: the
buffer they were named after is gone.

Inspired by Whatcookie's work on arm64 for rpcs3.
2026-08-09 11:20:53 +02:00
pstef 5b2713c220 Comments: stop calling a CPU tick a microsecond
Both comments predate GetCPUTicks() reading CNTVCT_EL0 and give a tick
scale this host does not have; one of them leaves a plain tick count
looking like a duration. The code under them already divides by
GetTickFrequency(), and is unchanged.
2026-08-09 11:20:53 +02:00
pstef 2e39fcc216 Optimization: wait on the address instead of spinning the pipeline
Each of the three spin-then-sleep semaphore loops watches a single
atomic word, but ShortSpin() has no way to know that: it spends its
share of SPIN_TIME_NS in batches of eight isb, and every one of those is
a pipeline flush. arm64 can watch the word itself, so ShortSpinOn()
does, and the loops that hold their whole predicate in one word take it.

Hosts that cannot watch an address keep the old spin.

Inspired by Whatcookie's work on arm64 for rpcs3.
2026-08-09 11:20:53 +02:00
pstef a9bd472a02 Fix FPU.cpp: answer the settled operands without running a digit
The recurrence returns the truncated quotient or one above it, and on a
large share of operands the remainder alone already says which.
eeDivideSignificand() and eeSqrtSignificand() now recognize those with
one integer division or one integer square root and return, so they no
longer run 24 digit steps. The frame, the caps, the share this covers
and which way the implication runs are at eeDivideCap().

The caps are the ones that used to ship in eeDivideTruncates(), which
the digit recurrence replaced.

eeISqrt48() comes back for the square root's half, its body unchanged
from the version the recurrence retired.
2026-08-09 11:20:53 +02:00
pstef b36688cd97 Fix FPU.cpp: carry the SRT digit as the masks its selector already produces
Both loops branched on the digit twice per step to choose the addend.
The selector's two comparisons are already the masks those selects want,
so it returns them and the digit is never rebuilt as -1/0/+1 inside the
step; why that form is at EeSrtDigitMask.

Square root takes the same shape, with both candidate addends and both
candidate roots built off the root before the digit arrives. That also
drops its integer digit.

No result changes.
2026-08-09 11:20:53 +02:00
pstef 5d7e8fe13d Fix FPU.cpp: run the EE divide unit's digits instead of rounding a quotient
DIV.S, SQRT.S and RSQRT.S applied a partial truncation law and fell
through to a correctly rounded host divide, which left every operand the
law did not reach one ULP away from the console. The unit is not a
rounding rule at all: it is a radix-2 SRT digit recurrence, so
eeDivide() and eeSqrtBits() now run the digits.  What that recurrence
is, and what it was measured against, is at eeSrtDigit().

The recurrence is not ours: it is PS2Float.cpp's Div() and Sqrt() from
GitHubProUser67's proposed PCSX2 soft-float series, carried and since
revised in the pcsx2-reliquary fork, whose only documentation is a DOI.

The three ops are integer now, so ScopedDivRoundMode and eeISqrt48 go
with them and FPUDivFPCR's rounding mode no longer reaches the
interpreter.

The divide-unit tests move with it. They were written around a model
with a residue: the two tripwires are enabled, the console tables assert
the console on every cell, and the engine differentials now pin the
shape of the one-ULP divergence that is left rather than the region the
old law settled.
2026-08-09 11:20:53 +02:00
pstef 29519881fa Fix: decide the multiplier's deficit from the array, not from ft
The interpreter modelled the one-ULP deficit with a closed form over
ft's mantissa. That form only holds where the exact product is
representable: once there is a tail below the ULP the decision needs fs
as well. Those rows came back IEEE.

Replace it with the array - Booth recode, carry-save tree, truncated low
columns - and read the decision off the column the truncation lands on.

eeMulDefectiveFt goes away, so the interpreter now models a superset of
what the arm64 emitters do. The emitters keep their cut of the closed
form; the comment in iFPUd-arm64.cpp says why. The randomized
differential classifies rows by the tail below the ULP and licenses a
second divergence, and a new console-row test pins the class the closed
form could not reach.
2026-08-09 11:20:53 +02:00
pstef 89f1e37bfe Fix FPU.cpp: the interpreter flushed underflowing add/sub results; silicon does not
A COP1 result strictly below 2^-126 and not zero is not always flushed:
the add/sub family keeps the mantissa bits where normalisation left them
and forces the exponent field to 0, while the multiplies and the divide
clear them. The 72 console rows, how they were sampled and how they rule
out flushing and the true denormal value, are in
ee_fpu_underflow_console_tests.cpp.

Only the mode-3 recompiler had it, so on every one of these inputs the
interpreter was the less accurate engine. eeRoundToSingle takes an
addsub flag and eeGuardedAddSub is what passes it, which covers all
eight add/sub-family ops. The underflow fold also moves ahead of the
(float) cast.

The single-precision fast path computes in single and cannot hold these
values at all, so it is pinned as a divergence.

EeFpuGuardedAddSubConsole's randomized test caught the interaction on
its own.  Its model computes in host floats, which FZ has already
flushed, so it cannot express the new rule; underflowing pairs are
skipped there and counted.
2026-08-09 11:20:53 +02:00
pstef 8d91f64af1 Test: pin the ADD/SUB-family underflow mantissa model
Nothing covered ToPS2FPU_Full's `addsub` arm, where a full-mode result
that lands below 2^-126 without being zero keeps the double's mantissa
bits instead of flushing. Rows for the eight ops that reach it (ADD,
SUB, ADDA, SUBA, MADD, MSUB, MADDA, MSUBA) and for MUL/MULA, which take
addsub=false and flush.

The rows are not a claim about silicon: no arithmetic op in the hardware
corpus has a denormal result, on hardware or on either engine, so
nothing can referee the model. They pin what mode 3 does today. The
block comment carries the model, the x86 provenance and how the other
two engines differ.
2026-08-09 11:20:53 +02:00
pstef d20b3bf0f2 Test: pin SQRT.S against all four divide-unit rounding modes
The previous commit moved eeSqrtBits() to integer arithmetic, which
turned FPUDiv.Roundmode from something the host FPCR delivered for free
into something the function reads and acts on itself. Nothing in the
suite covered that.

Four operands, one per case the truncation law and the rounding mode can
land in, plus an assertion that two of the four rows move with the mode
at all.

The comment at eeSqrtBits() now covers the one mode where the law and
the mode disagree, toward-positive-infinity, and which of the two wins
there.
2026-08-09 11:20:53 +02:00
pstef 34f757a475 Fix FPU.cpp: model the EE divide/square-root unit's truncation law
Eleven rounds of console captures went looking for the whole rounding
rule of the EE's divide/square-root unit and did not find it. One
fragment of it is settled: past a per-branch bound on how far the exact
result sits below the upper candidate, the unit truncates. eeDivide()
and eeSqrtBits() now apply that fragment and keep the correctly rounded
answer everywhere else. A fragment can ship because the implication runs
one way. The frame, the caps and the captures behind them are in the
block comment at eeDivideTruncates().

Both recompilers keep the host's correctly-rounded fdiv/fsqrt, so the
interpreter now leaves them behind on those rows. The three fuzz
differentials stop asserting that the engines agree and assert instead
the shape they may differ in.

Two rsqrt.s rows of the console capture regress, and are named in
ee_fpu_divunit_console_tests.cpp. Both were right by cancellation:
a root one ULP high, then a division that rounded up to the word silicon
reached by truncating a smaller divisor. Modelling the square root
removed one half of the pair; the other half is a division with u =
6,884,762 against a cap of 10,043,841, inside the region the law does
not settle. Both rows now have a sqrt.s column that matches silicon
- the console's sqrt.s 4938608B is 445941C1, and the tree produces
exactly that.

SQRT_S now calls eeSqrtBits(). It carried its own copy of the zero case
and of the exponent-255 prescale, which is the drift that helper existed
to prevent.  The prescale is retired with it: |Ft|/4 and the doubled
result were only ever a way to keep an ordinary EE binade inside a host
single, and in integers exponent 255 is the k = 23 path.
ScopedDivRoundMode goes too, since the integer path reads FPUDivFPCR
directly.

ee_fpu_divunit_exhaustive_tests.cpp is new. It pins what the exhaustive
captures rule out - the unit is not a rounding rule, and the decision
is not a function of (branch, divisor, u, nu2(T+1)) - next to the
silicon witnesses for the part that shipped.
2026-08-09 11:20:53 +02:00
pstef 6e46d5a67e Measure: the EE's divide/square-root unit is not correctly rounded
rsqrt(EEMAX, EEMAX) is not an RSQRT question. Two captures on the
SCPH-90000, 2231 operand pairs run through sqrt.s, rsqrt.s and div.s,
say two things about the unit underneath it.

RSQRT.S is sqrt-then-divide and nothing cleverer: rsqrt.s Fs, Ft came
back bit-identical to div.s Fs, S with S the sqrt.s Ft silicon had just
produced, on every row of both captures, with a plain 24-bit single in
between. That is what RSQRT_S already computes, and it is now pinned.

Neither step is correctly rounded. sqrt.s lands one ULP low on a large
minority of arbitrary operands and never high, div.s misses in both
directions, and rsqrt.s compounds the two into two ULPs. The error is
deterministic, but it is not a rounding mode and not a function of
either operand alone, so there is no cheap model to adopt.

ee_fpu_divunit_console_tests.cpp carries 87 console rows and the tallies
behind all of this. It asserts the sqrt-then-divide decomposition in the
engine, the 193 op-rows where the unit is exact, the 68 where it is not
and by how many ULPs, and that the fast path does not drift off the top
binade.  DISABLED_InterpMatchesConsoleOnEveryRow is the tripwire a real
model of the unit would have to pass; run today it fails on exactly
those 68 assertions.

The RSQRT_S comment blamed the row on the two-step rounding above it; it
is corrected to what was measured.
2026-08-09 11:20:53 +02:00
pstef 817431ee26 Fix FPU.cpp: the compares were the operand clamp's last caller
The exponent-255 clamp came off the EE arithmetic in previous commits
and was left on the compares, on the grounds that clamped and unclamped
compares can only differ when both operands are in the top binade and
that no captured row is like that. That reason went into FPU.cpp and it
is wrong: one clamped operand is enough, because it only has to collide
with an operand that already is 0x7F7FFFFF.

C_cond_S reads eeToDouble() now, and that was fpuDouble()'s last caller
with anything to do. Its only other site was SQRT_S's else branch, where
the exponent is 1..254 and both of its rewrites are the identity, so
fpuDouble() and fpuOperandBits() are deleted and eeToDouble() is the
single operand entry point in the file.

The fast path keeps its clamp: it compares in host singles, where every
top-binade word is a NaN and an unclamped Fcmp goes unordered. What
making it exact would cost is at ee_fpu_compare_console_tests.cpp.

That file transcribes all 86 compare rows of the capture: every c.f.s,
c.eq.s, c.lt.s and c.le.s case, with the expected FCR31 put through the
same mask CFC1 applies, so each row is a flag test as well as a
condition test. It asserts the interpreter, fpuFullMode
(DOUBLE::recC_*_xmm, which does not clamp) and the fast path separately,
the last including the four rows it is expected to miss.

EeRecFpu.CEqSInfinityClampsToMax asserted CC set for c.eq.s(2^128,
FLT_MAX), the clamp's answer taken for the PS2's. It is rewritten as
CEqSTopBinadeSplitsTheInterpFromTheFastPath, which asserts both tiers
and says which one is the console's. Same inverted-premise shape as the
four MAX/MIN tests and MulSFpuMulHackOffGivesNativeProduct.
CEqSPositiveNaNBothClampToMax becomes CEqSPositiveNaNIsEqualToItself: it
passes because a bit pattern equals itself, not because of any clamp.
2026-08-09 11:20:53 +02:00
pstef 79f29cf17e Test: pin the EE's lack of an absorbing value, measured on silicon
Every other FPU test in this directory measures one instruction, so none
of them reaches the later instruction the fast path's result clamp
(fpuClampResult) exists to protect. These rows are producer/consumer
pairs, which do.

The capture, the console rows read off it and the per-engine scores are
in the new file's header. An exponent-255 word turned out to be an
ordinary finite number to every consumer measured, so there is nothing
for the clamp to keep out of the register file.

What it does instead is hide absorption rather than prevent it: `mov
EEMAX` then `x*0` still goes host-NaN*0 -> NaN inside the fast path, and
the clamp folds that to 0x7F7FFFFF where the console says 0, a plausible
wrong answer instead of an obvious one. It only looks protective where
the producer was itself an arithmetic op whose own clamp had already
saturated.

Deleting it scores better and is still not the answer; the trade is at
EeFpuCascadeConsole.FastPathNeverInventsAnAbsorbingNan. Hence no code
changes here.
2026-08-09 11:20:53 +02:00
pstef fbf647cf2b CMake: Correct the -ffp-contract rationale: x86 contracts too
The comment above the -ffp-contract=off line claimed

  # -ffp-contract=fast is fine on x86 (no FMA emitted without -mfma)

and that is false: -mfma is not the only route to the instruction. The
default non-multi-ISA build takes -march=native
(cmake/BuildParameters.cmake, the DISABLE_ADVANCE_SIMD=OFF branch),
which supplies FMA on any host that has it.  Upstream PCSX2 v2.7.508
built exactly that way on amd64, emits

  MADDA_S  0x5084ae:  vfmadd213ss  0x590(%rdx),%xmm1,%xmm0
  MSUBA_S  0x5088be:  vfnmadd213ss 0x590(%rdx),%xmm1,%xmm0

so their EE FPU interpreter produces different results in a dev build
than in the multi-ISA release builds. Left standing, that sentence is
the reasoning that narrows this line to if(ARCH_ARM64) the next time
someone tidies up.

Also brought the two halves up to date. FPU.cpp has nothing left to
contract: two previous commits replaced its host-float arithmetic with
an explicit model (fpuAddSubGuarded, eeMulAccumulate), so the `_FAValf_
+= fs*ft` hazard the comment described is gone, and contracting it today
moves none of the 1147 capture cases. The VUops.cpp half points at the
SCPH-90000 capture for what the console does, instead of asserting that
the hardware has no FMA.

Comment only; the compile options are unchanged.
2026-08-09 11:20:53 +02:00
pstef 80fd5491f6 Tests: VU MADDA/MSUBA against hardware — the VU FMAC rounds twice
VUops.cpp is compiled with -ffp-contract=off (pcsx2/CMakeLists.txt) so
the compiler cannot turn `acc + fs * ft` into a single-rounded fmadd.
The only test behind that line was
EeVu0Cop2Macro.Jak3CameraBasisKernelChainMatchesInterp, which compares
the two engines to each other and so cannot say how many times a
PlayStation 2 rounds.

A capture on an SCPH-90000 settles it over ten operand triples, eight of
which tell a fused accumulate apart from two separate roundings. The
console took the two-rounding answer on all eight, and both engines
already reproduce all ten. The operands, the three candidate results and
the two control rows are in the new test file.
2026-08-09 11:20:53 +02:00
pstef d71643fb75 Fix: DIV.S and RSQRT.S, the last two ops holding the operand clamp
These two were the last arithmetic reading their operands through
fpuDouble() and saturating at posFmax, so the EE's top binade could not
reach them.  eeDivide() normalizes both operands and reassembles the
exponent, and eeSqrtBits() gives RSQRT the exponent-255 prescale SQRT.S
already uses. Why neither can take the eeToDouble() route the rest of
the family took is at eeDivide.

Two results move with them. Saturation goes to the EE's own maximum
0x7FFFFFFF, a binade above the fast path's FLT_MAX, so the tiers now
differ by design and the tests that used to diff them run their legs
separately. And RSQRT's zero-divisor sign comes from Fs rather than Ft,
matching upstream x86 recRSQRThelper1 and the console; DIV's xor of the
two operands is untouched.

The interpreter column of
EeRecFpuFull.RsqrtAboveEeMaxSaturatesInsteadOfWrappingToNegativeZero
moves to 0x7FFFFFFF with it, and the zero-divisor and RSQRT tripwires
that named this commit as their condition graduate.
2026-08-09 11:20:53 +02:00
pstef 312304a0ea Fix FPU.cpp: the interpreter clamped away the EE's top binade before every op
Exponent 255 is an ordinary exponent on this FPU: 0x7F800000 is 2^128,
0x7FFFFFFF is the largest number the machine has, and there is no Inf
and no NaN. That is one binade above IEEE single, so fpuDouble() folded
every such operand down to +/-0x7F7FFFFF on the way in and
clampToEeRange() folded a host infinity back to the same word on the way
out.

ADD.S, SUB.S, MUL.S, their A-forms and the four multiply-accumulates now
read their operands through eeToDouble() and round once through a new
eeRoundToSingle(). Why the double arithmetic in between is exact, and
how the top binade is rounded with no host single to round it in, is at
eeGuardedAddSub, MUL_S and eeRoundToSingle.

The multiplier's one-ULP deficit had to move across with it or it would
have been lost here: it sat on top of fpuDouble() and read the operands
as clamped, which changed ft's mantissa and so changed its own
predicate. eeMulRound applies it to what eeRoundToSingle produces
instead, and the six multiply forms reach it through eeMulProduct's
replacement, eeMulAccumulate.

One behavior changed beyond the range: an overflowing product now ends a
MADD/MSUB, value as well as flag, where the value path used to carry on
accumulating and land a binade away.

The fast path is untouched. It computes in host singles and cannot hold
these values at all; the warning against changing posFmax to suit it is
at the top of ee_fpu_overflow_console_conformance_tests.cpp.

DIV and RSQRT still read operands through fpuDouble and still saturate
through checkDivideByZero's posFmax. A quotient of two singles is not
exact in a double, so the eeToDouble route would double-round under the
divide unit's round-to-nearest; they need the normalise-and-reassemble
treatment the next commit gives them.
2026-08-09 11:20:53 +02:00
pstef ef98dd521e Fix FPU.cpp: the interpreter's adder had guard bits the EE does not have
The EE adder carries no guard bits to the right of the mantissa, so
a cancelling add or sub comes back one ULP toward zero from the IEEE
answer. x86 FPU_ADD_SUB and both arm64 emitters have masked for that for
years; the interpreter did not, which made it the less accurate engine
on this class.

fpuAddSubGuarded() ports the recompilers' mask into ADD.S, SUB.S,
ADDA.S, SUBA.S, MADD.S, MSUB.S, MADDA.S and MSUBA.S, unconditionally: it
does not read CHECK_FPU_GUARDED, so with fpuGuardedAddSub=false the two
engines disagree here by design, and
EeRecFpuGuardBit.GuardOffDivergesFromInterpreterByDesign asserts both
sides so that is not closed by accident.

fpuDouble() now reads the shared fpuOperandBits(), and MADDA.S/MSUBA.S
name their product instead of accumulating in one expression; the
comment at MADD_S has the rest.

ee_fpu_guarded_addsub_console_tests.cpp is the new console pin for the
class: the capture rows the port moves, the eight family members,
aliasing, a randomised sweep against the x86 model on both engines, and
the FCR31 axis it must not disturb.

Not fixed here: fpuOperandBits() still clamps an exponent-255 operand to
+-0x7F7FFFFF before the adder sees it, so a row with such an operand
gets one step closer to the console without reaching it. That is the
operand-clamp family SQRT.S already left (1a09344ba6).
2026-08-09 11:20:53 +02:00
pstef 88903b7ce9 Fix FPU.cpp: the interpreter took FCR31's O and U from a host Inf or denormal
checkOverflow() asked whether the result had come back as a host
infinity and checkUnderflow() whether it was a host denormal. Neither
ever appears under the FP environment a game runs in, so O and U were
raised only under a rounding mode no game selects: 18 of the 57 rows in
the first-party capture read back with the flags clear where the console
raised them.

Decide from the magnitude of the exact result instead, computed in
double by a new eeToDouble(). The multiply-accumulates round twice and
so raise twice; the block comments at raiseOrClearOU() and
madAccumulandOverflowed() carry the silicon rows the rule was read off.
The value path keeps the old helpers' folding, now in clampToEeRange(),
so this is a flag change end to end.

The family table in ee_fpu_fcr_console_conformance_tests.cpp called all
ten of its "overflow" rows overflows. Eight are +/-FLT_MAX sums landing
exactly on 0x7FFFFFFF, which is representable and which silicon does not
flag: the four adds move to the clear class, and the four
multiply-accumulates get operands that overflow the product instead. The
raise class is six multiplies and two underflows, and the clear class
grows to one row per emitter that owes a clear.

Because the answer no longer depends on the rounding mode, the two
DISABLED tests that said opposite things about that table in the two FP
environments are gone; ExceptionFlagsMatchConsoleExceptTheFastPathRaise
replaces them and runs both. EnginesAgreeOnTheOverflowFlagClear,
NanMathOverflowIsNotAnOperandClampModeDifference and
OverflowFlagsComposeAcrossOneBlock graduate from DISABLED, and
InterpreterRaisesOverflowAndUnderflowLikeTheConsole and
FullModeMatchesConsoleOnEveryRow are new.
DISABLED_UnderflowFlagsNeedFzOff stays disabled: what is left in it is
the denormal-result value, which no capture can settle, since the
console's FPU has no denormal results to capture.

The fast path is not touched. It clears O and U and raises neither, so
the tests pin its FCR31 as the interpreter's minus exactly O|U|SO|SU
rather than as a row list. A raise there needs the double arithmetic
fpuFullMode already pays for; the reverted fast-path emitter took the
cheap route and derived O from a host-Inf predicate, so it was
a function of eeRoundMode and fired on rows that are not overflows, for
+8 host instructions on every arithmetic op.
2026-08-09 11:20:53 +02:00
pstef d85814463d Fix FPU.cpp: SQRT.S of an exponent-255 operand, by scaling instead of clamping
Exponent 255 is an ordinary binade on the EE - no Inf or NaN, and the
representable max is 0x7FFFFFFF rather than FLT_MAX - so an exponent-255
operand never needed saturating. The interpreter clamped it to
+/-FLT_MAX inside fpuDouble anyway, and landed short of the console:

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

It now computes sqrt(|Ft|/4)*2, which recSQRT_S_xmm already emits. Why the
factor is 4 is at SQRT_S in FPU.cpp.

The two conformance tests that pinned the clamp pin the console value
instead, over every exponent-255 shape rather than the three the capture
holds.
2026-08-09 11:20:53 +02:00
pstef 550eaba1c0 Fix FPU.cpp: 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 an ordinary +0, raise
invalid-operation there too.  Those were the only two operand classes
that lost the flag.

From the first-party capture that records FCR31 alongside the result:

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

The rule holds across all 38 SQRT.S rows. A positive qNaN raises
nothing.

The fix is a deletion on both sides: the Tst(0x7F800000)/B.eq pair in
the arm64 fast path, and the flag set hoisted out of FPU.cpp's
negative-normal arm. 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; the arm64
FULL-mode DOUBLE::recSQRT_S_xmm tested the sign alone too and was
unaffected.

A previous commit fixed the value half of a case where the interpreter
returned _FtValUl_ & 0x80000000 and so answered -0 where the console
answers +0.

EeRecFpu.SqrtSInvalidFlagFollowsTheSignBitAlone is the ten-row sign x
exponent matrix from the capture, each engine scored on the full FCR31
word. The flag half of EeRecFpu.SqrtSOfNegativeZeroIsPositiveZero moves
there; it had asserted the opposite on nothing but the two engines
agreeing.

Two leads from the same sweep, recorded in the capture's handoff and not
addressed here. 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.
2026-08-09 11:20:53 +02:00
pstef 9f0165f167 Tests: VU pipeline visibility, memory wrap and XGKICK wrap against hardware
A 128-case first-party console capture for the three remaining areas of
the adjudication request that had no oracle: how far downstream a Q, P
or flag result is readable, VU data-memory addressing at the top and
across the VU0->VU1 window, and whether an XGKICK packet read wraps.
autocases_vulat.h carries the capture; the headers of
vu_pipeline_console_conformance_tests.cpp and
vu_memory_xgkick_console_conformance_tests.cpp list what it established,
in the order the tests assert it.

Every number and every rule we already model came back correct, apart
from the two below.

Two divergences are pinned as DISABLED tripwires, each naming its cause
at the test: InterpreterPublishesStickyDivideFlagsEarly (the interpreter
publishes the sticky half of a divide-unit cause bit at distance 5,
where console and microVU publish it with the quotient at 7) and
Vu0HasNoElementaryFunctionUnit (we dispatch the EFU family on VU0 and
model a P register for it; hardware has no such unit).
2026-08-09 11:20:53 +02:00
pstef 7f08c8b364 Tests: VU0 branch terminators, integer pipe, MAX/MINI and CLIP against hardware
A 61-case first-party console capture for four areas that had no oracle:
the branch terminators, the integer pipe, MAX/MINI and the clip
register.  autocases_vubranch.h carries the capture; the header of
vu_branch_console_conformance_tests.cpp lists what it established, in
the order the tests assert it.

Three known divergences are pinned as DISABLED tripwires, each naming
its cause at the test: MacUnderflowBitMatchesConsole (both engines drop
the MAC underflow bit after a flushed product), DBitStopMatchesConsole
(the arm64 recompiler does not implement the D bit) and
InterpreterRunsDelaySlotBeforeTraceStop (the interpreter stops a pair
early and never runs the delay slot).
2026-08-09 11:20:53 +02:00
pstef 0c197d4d3a Tests: EE FPU overflow against hardware — the max is 0x7FFFFFFF, not FLT_MAX
The only hardware evidence for the EE's top exponent was ps2autotests'
fpu/fcr.cpp, which runs MUL.S(0x7F7FFFFF, 0x7F7FFFFF) but prints the
result with %f and so records 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. The rule they establish is at the top of
autocases_fpuovf.h; the generator checks it in exact rational arithmetic
across all 47 arithmetic rows.

Every console divergence in the capture is shared by both engines and
deliberate, since the fast path cannot hold the EE's top binade at all,
so the conformance test carries the console column as data, classifies
the divergences at the top of the file, and leaves them to the
hardware-alignment stage.

One gap is not deliberate: recSQRT_S_xmm is the only emitter in
iFPU-arm64.cpp that never clamps its operand, and no clamp mode closes
the rows it splits.  Recorded as a divergence with a DISABLED tripwire,
not fixed here.
2026-08-09 11:20:52 +02:00
pstef 6cc085d024 Tests: FCR31 O and SO against hardware, as tripwires
pcsx2/FPU.cpp maintains FCR31's O and U on every EE FPU arithmetic op:
checkOverflow saturates an infinite result to +/-fMax and raises O with
the sticky SO, clearFPUFlags takes them back down. The recompiler's fast
path maintains neither, so an overflowing MUL.S leaves FCR31 reading
0x01000001 where console says 0x01008011. The interpreter is the side
that matches the console, so the recompiler is the side that has to
move.

kFamCases sweeps the whole class, every arithmetic op that touches O or
U, rather than the two rows the capture happens to reach. Five tests go
in DISABLED, because the emitter that would have closed them was
measured and reverted; what it got wrong and what a redesign owes are in
the comment above DISABLED_EnginesAgreeExceptOnTheOverflowFlags.
DISABLED_UnderflowFlagsNeedFzOff holds the U half, which needs FZ off
and brings the denormal-value question with it.

The one cross-engine row left, "NAN math", is not about the missing
flags: ADD.S on two raw exp-255 words has the engines computing
different things before any flag logic runs, and
CHECK_FPU_EXTRA_OVERFLOW aligns it exactly.
DISABLED_NanMathOverflowIsAnOperandClampModeDifference attributes it to
the operand-clamp mode axis instead of leaving it unexplained in
kFcrEngineDivergences.
2026-08-09 11:20:52 +02:00
pstef bf3880147b Tests: assert cross-engine agreement across the console-capture corpora
Every console-conformance corpus scores each engine against the hardware
capture separately. That is deliberate - a JIT-vs-interp differential is
blind to what both engines get wrong together - but it left the
mirror-image blind spot: nothing asserted the engines match each other.
Two engines returning different wrong answers get recorded as known-bad
twice and look settled. That is how 111 EFU divergences hid.

The MMI, SA/branch/perf and EE LSU corpora get a plain agreement test
with no allowances. FCR31 gets one with two recorded divergences:
outside the EATAN family b55b1b7510 already lists, it is the only corpus
where the engines differ, and on both rows the interpreter is the side
that matches silicon, returning 0x01008011 where the arm64 recompiler
returns 0x01000001 and drops O and SO. FPU.cpp raises the pair from
checkOverflow(), iFPU-arm64.cpp models no FCR31 exception flags at all,
and the x86 emitter's FPUflagO/FPUflagSO writes are commented out, so
the fix belongs in the recompilers.

The split only shows at round-to-nearest, which is why this was filed as
unanimous-but-wrong before.
2026-08-09 11:20:52 +02:00
pstef 5e699be1b8 Tests: record the arm64 VU div-unit denormal-Q divergence, both pipes
The VU has no denormal encoding, and the interpreter models that by
running every div-unit result through vuDouble, whose exponent-0 arm
returns a bare signed zero. Both arm64 pipes write the raw quotient
instead: COP2 macro through cop2EmitSyncFDiv, micro through writeQreg.
Each is the single tail all three of DIV/SQRT/RSQRT pass through, so the
class is one site per pipe.

Deficient: arm64. Correct: interp. Two tripwires, one per pipe:

VuStickyConsoleConformance.DISABLED_Arm64Cop2DivUnitFlushesDenormalQToSignedZero
VuStickyMicroConsoleConformance.DISABLED_MicroDivUnitFlushesDenormalQToSignedZero

They replace a macro-only tripwire that had stopped tripping. It was
captured before the harness moved into the production FP environment,
where DenormalsAreZero flushes the quotient before either engine can
differ, and after that it passed silently, as a disabled test, while the
defect it named stood. Both replacements scope ScopedFpEnv{IeeeNearest}
and assert FZ clear on the register their pipe actually runs under.

A software flush was written in both pipes and reverted: the VU FPCR's
FZ already does it in every shipping configuration, and it only
half-closes the wider gap the micro tripwire records.

That attempt exposed a hole in the ABI-digest backstop: none of its
seven probe programs contains a lower-pipe div-unit op, so the whole
mVU_DIV/mVU_SQRT/mVU_RSQRT emitter, and writeQreg behind them, could be
rewritten without moving a digest. A divUnit probe now covers it.
2026-08-09 11:20:44 +02:00
pstef d95cbceab8 Tests: repair the dead ADD.S negative control, and make it self-verifying
ArithmeticStillChopsUnderTheAmbientMode was added as a negative control:
ADD.S does not belong to the divide unit, so it must keep chopping under
the ambient mode, and nothing else in the suite would catch a fix that
widened the FPUDivFPCR swap to the whole FPU.

It was dead. The operands were 1.0 + 2^-25, justified in the comment by
"the exact sum needs 26 mantissa bits, so it rounds to 0x3F800001 at
nearest". The exact sum sits a quarter ULP above 1.0, well below the
halfway point, so round-to-nearest returns 1.0 as well -- 0x3F800000
under both rounding modes and both settings of fpuGuardedAddSub. The
control asserted a value that could not change, and passed for a reason
unrelated to what it claimed to test.

New operands 1.0 + (1 - 2^-24) do separate the two modes. The test
asserts the chopped result, then re-runs the same operands under a
scoped round-to-nearest ambient FPCR and asserts the other one, so
a later edit that flattens them fails here instead of passing quietly.
2026-08-08 16:03:22 +02:00
pstef 332af1a82d Tests: EE zero-divisor result against hardware, across the clamp modes
DIV.S and RSQRT.S special-case a divisor whose exponent field is zero
and saturate instead of dividing. That saturated value differs from the
console in two independent ways, magnitude and sign. The capture rows
and the rules read off them are in the new file's header.

Neither is simply a bug to fix. The console value already ships, in the
FULL path, and the fast path's +FLT_MAX is a deliberate compromise. The
gap is the interpreter, which has no FULL path at all, so an
eeClampMode:3 game gets the console value from the recompiler and the
fast-path one from the interpreter.

Hence four tests rather than two. One pins the JIT's console-exact
FULL-mode result, which nothing covered before; it is the only one
enabled here. One pins the fast path's non-console value in the default
mode, so a later attempt at either tripwire cannot change that mode by
accident. The last two are the tripwires for the interpreter gap,
magnitude and sign split apart so they can graduate independently, the
sign one carrying the DIV rows as a control.

The default-mode test ships disabled as well: the interpreter still
reads DIV/RSQRT operands through fpuDouble(), so it cannot agree with
the fast path yet. "Fix: DIV.S and RSQRT.S, the last two ops holding the
operand clamp" re-enables it.
2026-08-08 16:03:16 +02:00
pstef fdfc7cf4bd Fix FPU.cpp: the interpreter's divide/sqrt unit rounds to nearest
The interpreter ran DIV.S, SQRT.S and RSQRT.S under the ambient FPUFPCR
rather than the divide unit's FPUDivFPCR, so it chopped where both
recompilers and the console round: one ULP low on every inexact result
in the default mode a game runs in. It now takes the same swap, gated
the way the emitters gate it; ScopedDivRoundMode in FPU.cpp has the
rest.

The witness this was first reported against, 1.0 rsqrt 1.5 landing on
0x3F5105EC where hardware gives 0x3F5105EB, has two independent causes.
749392120f fixed one. The value came back when the harness moved to the
production FP environment and was read as that fix half-landing, "the
divide is still in double". It is not: fpuDouble returns float and the
quotient was always single.

Four EeFpuOverflowConsole cases go DISABLED here: their SQRT.S rows were
written against the interpreter's truncated values. "Tests: EE FPU
overflow against hardware" and "Fix: SQRT.S raises invalid on -0 and
negative denormals" later in the series bring them back.
2026-08-08 16:03:03 +02:00
pstef 48ccc17f63 Tests: take the FCR31 O/U flag material out of the capture file
It re-lands later in this series, so the FCR conformance file goes back
to covering the console capture and nothing else. Out with it:
EnginesAgreeOnTheOverflowFlagClear, the kFcrEngineDivergences list, the
FamCase table and RunFamCase, and the five DISABLED tripwires over them.
2026-08-08 16:02:59 +02:00
pstef 243e061c84 Tests: record the arm64 COP2 macro divergences against the console
The comments around the divergence tables described an emitter that
derives all four ZSUO predicates from the raw result's exponent field,
and a MAC table emptied because of it. Neither is in this tree:
cop2EmitFlagUpdate extracts a sign bit and a zero bit per lane and
nothing else, both tables still carry their rows, and the U/O tests are
still DISABLED. Restate the comments against the code that is here, and
correct the STATUS table's row split, which was already wrong.

The micro-mode note had drifted onto kMacroMacDivergences; put it back
over kMicroDivergences.

Add a tripwire for the DIV unit, the same defect one pipe over: the
interpreter runs its quotient through vuDouble, which flushes a denormal
Q to signed zero, and the arm64 DIV/RSQRT emitters store the raw
quotient.
2026-08-08 16:02:51 +02:00
pstef 80465151c4 Fix FPU.cpp: RSQRT.S rounds the square root to single before dividing
On the positive-divisor path the interpreter divided by the unrounded
double that libm sqrt returns, so its quotient came out 1 ULP from the
EE FPU and from both recompilers. The negative path already rounded
through temp.f; both paths now share the same two expressions. The
comment at RSQRT_S() names the recompilers and carries the operands and
both results.
2026-08-08 16:02:36 +02:00
pstef 7cae16e8e6 Fix: DXSTG translates its tag instead of aiming a write-back at a host address
PCSX2's D-cache tag holds a host pointer -- CacheLine::load stores the
translated `ppf` -- where hardware holds a guest physical address, and
DXSTG copied CP0 TagLo into that field unmasked, so writeBackIfNeeded
dereferenced a word the guest chose. It now translates the tag page the
way a fill does and takes isValidPFN from the same translation; the
comment at the case has the rest.

That widens the write-back precondition: a DXSTG naming a resolvable
page arms a line that was never filled, where before only a fill could
set bit 11. That is the hardware behaviour.

The exposure was narrow. The wild store needed isValidPFN standing from
a real fill, and fills are gated by EnableEECache, which ships off; the
tag write itself was never gated. Cache.cpp is shared, so x86 was
affected identically, and both recCACHE() bodies are empty, so the
recompilers reach this through the interpreter.

DISABLED_DxstgDirtyStaysInsideGuestMemory graduates.
Pcsx2DxstgRedirectsAHostWrite, which pinned the defect, becomes
DxstgWriteBackTargetsTheTaggedGuestPage, and
DxstgOnAnUnresolvablePageDeclinesTheWriteBack is new.
Pcsx2AcceptsDirtyFromDxstgButCannotReportIt used the redirect as its
instrument and now reads guest memory.
2026-08-08 16:02:13 +02:00
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 8e91a17905 Optimization: widen straight out of the operand register, dropping the copy
Every full-mode widening site paid an Fmov to copy an allocator-resident
operand into a temp before ToDouble, for one reason: ToDouble's
exponent-0xff has to place the exponent-lowered single somewhere before
Fcvt, and it parked it in the register it was reading.

ToDoubleFrom(dst, src) puts it in the destination's S lane -- a temp the
caller already owns -- so it never writes the source. ToDouble(idx) is
now ToDoubleFrom(idx, idx) and emits the same instructions it always did.

Instructions removed on the common path: 2 per MUL.S/MULA.S, 3 per
MADD/MSUB/MADDA/MSUBA (both operands plus the ACC), 2 per
C.EQ/C.LT/C.LE, 2 per DIV.S. recDIVhelper1 now takes the operand registers
separately from its two write-only temps; its zero-divisor block reads
fs/ft in place.
2026-08-04 20:23:38 +02:00
pstef b8b5c176aa Optimization: keep the MADD product wide between its roundings
recMaddsub rounded the product to a PS2 single with ToPS2FPU_Full and then
widened it straight back with ToDouble thirteen instructions later. Nothing
observed the narrow form in between except the guard mask, which only reads
exponent fields -- and those are as extractable from bits 52..62 of a double as
from bits 23..30 of a single.

Two new emitters make the round trip unnecessary: ToPS2FPU_Wide rounds to EE
precision without changing format, and FPU_ADD_SUB_D is the guard mask on
doubles.

Shaves off about 5 executed instructions per MADD/MSUB/MADDA/MSUBA, not
the 19 the round trip costs: the multiply stage still needs its flag
branches, because the O flag is what recMaddsub branches on. Output is
unchanged.
2026-08-04 20:21:55 +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
pstef 071bae1309 Tests: EE cache: nothing snoops, and the I-cache replaces like the D-cache
Rounds 1 and 2 established that an uncached load sees stale RAM while
a dirty line exists; the store direction was not tested, and it is the
one that loses data. To a clean resident line, RAM takes the store and
a later cached read still returns the old value.  To a dirty one, the
write-back overwrites it. And to a different word of a dirty line
- a word that never went near the cache - it is lost just the same,
because the write-back is 64 bytes wide and cannot know. That last case
is the one worth having: a game poking one word through UNCACHED_SEG
loses the poke if a neighbouring word happens to be dirty.

PCSX2 gets all three right and for the right reasons, so they are pinned
as agreement rather than recorded as divergences.

Filling an instruction-cache set means executing from it, so the probe
uses five ordinary compiled C functions at aligned(0x2000): 8 KB apart
leaves vaddr[12:6] alone, which given round 2's measured index width
puts all five in one set, and each is padded past four lines so four
independent sets are sampled at once. No hand-assembled bytes are
executed and the pre-flight decodes the five addresses out of the linked
ELF.

The rule is the D-cache's, way = LRF0 ^ LRF1 with the filled way's LRF
toggling, and it is checked by simulation rather than by eye: for each
of the twenty (depth, line) observations the rule is run forward from
all four possible initial LRF states and exactly one reproduces the
occupants and both LRF bits. Twenty rows, twenty unique fits. The
initial state is solved for rather than assumed because each round
starts from whatever the previous record's file I/O left behind. Two
side results:
FlushCache(INVALIDATE_ICACHE) leaves an invalidated line naming its old
address and keeps its LRF, the instruction-side counterpart of round 1's
DXIN finding; and PCSX2's D-cache driven from a reset model produces the
same LRF bits the console's instruction cache did.

cache 0x07 is IXIN - index-addressed, way-selected by bit 0, clears V,
keeps the tag address. Both earlier rounds only inferred the op number.
2026-07-26 11:05:54 +02:00
pstef 4c4ab8cc3f Tests: EE cache: I-cache is 16K/128 sets, DXSTG can set D
Round 1 saw a hit op at the 0x20000000 uncached alias of a dirty line do
nothing, and could not say whether hit ops are suppressed on uncached
pages or the tag compare is virtual, because every page it touched was
identity-mapped. This probe installs its own TLB entry - tlbwi at a free
index, guarded by tlbp so a duplicate match is impossible, restored
verbatim afterwards - giving the buffer a second cached virtual address,
and runs the identical sequence through each. Cached alias hits and
writes back; uncached does nothing. The compare is physical; the page
attribute is what excludes it.  Case 7 says the same from the ordinary
load/store path: one physical line from both addresses, one valid way,
no virtual-alias duplicate.

Round 1 deliberately never wrote D, so its recorded mask PTagLo|0x38
only said D reads back 0 when 0 is written. It does not: D is writable,
reads straight back, and steers a 64-byte write-back at the address in
the tag, on DXWBIN and on ordinary replacement alike. The implemented
flag field is D V R L, mask 0x78; the round-1 test is renamed and its
comment corrected.

And that turns out to matter. PCSX2 computes the write-back target with
the console's exact rule and accepts the dirty bit, but its tag holds a
HOST pointer where hardware holds a guest physical address, and DXSTG
copies the guest word in unmasked - so writeBackIfNeeded dereferences
it.  Pcsx2DxstgRedirectsAHostWrite maps a page at the host address the
guest asked for and watches 64 bytes land in it; unmapped, which is the
normal case since setAddr zeroes the top 32 bits, it is an emulator
crash instead. Not fixed.

What bounds it is EnableEECache, which ships off, but indirectly:
CHECK_CACHE gates only vtlb.cpp's guest load and store paths, not the
CACHE opcode or the cache entry points. The redirect needs isValidPFN
set when the DXSTG lands and only a fill can set it, so with the setting
off a guest cannot reach it - the never-filled and invalidated blocks of
the test pin that precondition. The tests themselves do not read the
setting; they drive the model directly, and this defect reproduces with
EnableEECache at its default false.

I-cache geometry. Relocating code to an address with bit 12 set would
have meant executing hand-assembled bytes on the console, so the probe
asks the cache instead: read every set at addr and at addr+0x1000 and
count how many differ. D-cache 0 of 64, I-cache 64 of 64 - index
vaddr[12:6], 128 sets. The D-cache run is the known-answer control.
Three further routes agree: the index wraps modulo 0x2000 (8K per way,
16K total), every valid tag's bit 12 equals its set's bit 6 (254/254),
and (tag & 0xFFFFF000) | (set << 6) reconstructs a line whose two IXLDT
words are the two words RAM holds there - for an upper-half entry, where
the two candidate reconstructions would differ if the tag dropped the
bit.

Incidental: the live TLB index 0 is {0, 70000000, 8000001F, 0000001F},
not ps2sdk's {..07, ..07} - same PageMask, EntryHi and PFN, C=3 not C=0.
Nothing here depends on it; the mapping this probe relies on came from a
live tlbp.

setValidPFN(false) in DXSTG contains the redirected write: it turns
DISABLED_DxstgDirtyStaysInsideGuestMemory green and flips two tests,
Pcsx2DxstgRedirectsAHostWrite + Pcsx2AcceptsDirtyFromDxstgButCannotReportIt
which measures D's survival through the same host write - and moves
nothing else in the suite.  Reverted; that is containment, not a fix,
and pcsx2/ is untouched here.
2026-07-26 11:05:54 +02:00
pstef 820e1016ef Tests: EE cache ops against PS2 hardware: DXLTG carries a paddr and does not flush
PCSX2 models the R5900 D-cache in Cache.cpp and the arm64 recompiler
models none of it (recCACHE(){}). Two of the model's own comments admit
to guessing, and both guesses are wrong:

  DXLTG returns line.tag.flags() with no address. Hardware returns
PTagLo[31:12] | flags, and ps2sdk's _SyncDCache/_InvalidDCache
reconstruct a line's address as (TagLo & 0xFFFFF000) + index to decide
whether to touch it - so under PCSX2 they read an address of zero.

  DXLTG calls writeBackIfNeeded() "for speed". Hardware does not, and
reports D=1; PCSX2 therefore makes memory more coherent than the console
and can never report a dirty line through a tag read.

Two more: invalidation zeroes the tag address where hardware keeps it
(and keeps LRF on both), and ALL_FLAGS=0x7FF lets bits 10..7 and 2..0
stick where DXSTG on silicon drops them - it accepts 0xFFFFFFBF and
reads back 0xFFFFF038.

PCSX2 is right about everything else, including the thing it was least
sure of.  Replacement is exactly `way = LRF0 ^ LRF1` with the filled
way's LRF toggling: five lines a page apart into one two-way set
alternate 0,1,0,1,0 on both engines. Also pinned: index = vaddr[11:6],
way = vaddr[0] with bit 1 ignored, DXLDT's word = addr[5:2], DXSDT,
write-allocate, write-back, DHWOIN, DHIN, DXWBIN, and TagHi untouched
throughout.

The instruction cache is real and readable. `cache 0x00`/`0x01` returned
a tag naming the bait function's line, and the way0 read landed on a
different line whose two words match what objdump shows at that address
in the linked ELF.  PCSX2 implements neither op.

Two observation classes are unscored with the reason stored beside the
number: which way a line lands in (invalidation preserves LRF, so a
reset model's history differs - the rule is comparable, the outcome is
not), and anything through the 0x20000000 alias (Memory.cpp unmaps it,
so the harness cannot ask the console's question).
2026-07-26 11:05:54 +02:00
pstef 43d4e84daf Fix: mask SA at MTSA; add tests: SA/QFSRV/link-branch/PCCR
A probe was built with the ps2dev toolchain, run on a PS2, and streamed
its results back over ps2link's `host:` channel. Its two records are the
oracle behind autocases_sa.h.

SA is four bits wide. `mtsa 0x10` leaves SA at 0, `mtsa 0xFFFFFFFF` at
0xF, and MFSA reads the masked value back, so the mask belongs at the
write. PCSX2 stored the raw 32 bits, which was the root cause of three
different QFSRV behaviors for sa >= 16: UB in the interpreter, a
guest-controlled out-of-bounds host read in the x86 recompiler, and a
defensive mask in the arm64 one - and upstream recMTSA masks on its
const path but not its runtime path, so x86 disagrees with itself too.
Masking at MTSA makes all of it unreachable at once.

Leaving it would mean permanently suppressing a JIT-vs-interp diff.
EeRecTraps.MtsaCopiesFullRegister and MtsaConstFoldsAtCompile asserted
the old behavior and were updated to the console's value.

Two defects found and recorded

1, link branches with rs == $31 judge the condition on the pre-link
value on console: `bltzal $ra` with $ra = -1 is taken. Their no-link
twins agree on all six probe values, so the link write does not feed the
comparison.  PCSX2 links first in six functions (EE BGEZAL / BLTZAL /
BGEZALL / BLTZALL, IOP psxBGEZAL/psxBLTZAL); 12 rows x 2 engines
diverge. This settles the question
IopLsuBranchConsoleConformance.LinkBranchesWithRaAsSourceArePinned left
open. The assembler refuses to encode these at all, which is why no
capture had them - they went in as raw .words, each verified by
disassembling it back. `jalr $31,$31` rode along as a positive control
and passed: the console jumps to the OLD $ra, corroborating the psxJALR
ordering fix on a second CPU.

2, PCCR's writable mask is 0x800FFBFE - exactly the layout R5900.h
documents, with pad0, pad1 and the eleven Reserved bits reading back as
zero. PCSX2 stores it verbatim; 14 rows x 2 engines diverge. PCR0/PCR1
are fully 32-bit writable, so PCSX2 is already right there.

DISABLED_AllSaPerfMatchesConsole is the graduation tripwire for both.
2026-07-26 11:05:50 +02:00
pstef 2aae26a187 Fix psxJALR, add tests: IOP loads, stores and branches
Fixes psxJALR: it read its branch target out of Rs AFTER writing the
link into Rd, so `jalr $t0, $t0` jumped to the link address instead of
the old Rs.  Hardware keeps the old Rs. rpsxJALR already stashed Rs to
pcWriteback before linking, so the two IOP engines were split and the
harness diff would have had to be suppressed to leave it. Reverting the
one-line reorder fails exactly that case and nothing else.

lsudelay is recorded as known-bad instead of asserted: PCSX2 retires IOP
loads immediately in both engines, so three of its nine cases diverge
from silicon on both, with DISABLED_AllLsuDelayMatchesConsole as the
tripwire.

Also pinned, none of it reachable by a differential test: BGEZAL/BLTZAL
link unconditionally, the delay slot always runs, the delay slot's write
to the link register beats the link itself, `sd`/`ld` are GAS macros
over lw/sw pairs rather than IOP instructions, and mfhi/mflo/mult/div
expose no HI/LO delay.

Six sibling functions share psxJALR's link-before-read shape but only
differ when Rs is 31, which the capture never exercised. Both IOP
engines agree there, so no claim is made; the shared behavior is pinned
by LinkBranchesWithRaAsSourceArePinned.
2026-07-26 10:41:26 +02:00