Commit Graph
100 Commits
Author SHA1 Message Date
Brian Degenhardt d08356d954 GameDB: give Rogue Galaxy the asynchronous GS download mode
Rogue Galaxy blocks the GS thread 7.5 ms every frame to read back sixty-four
pixels. It is an 8x8 patch of the depth buffer at a fixed screen position, read
once a frame -- a depth occlusion probe, the test a game does before deciding
whether to draw a lens flare. The cost is entirely GPU-fence synchronisation, so
it does not scale with the payload: the emulator submits, waits for the GPU to
finish, and reads 256 bytes.

The asynchronous download mode issues the same copy into a throwaway staging
texture and retires it at a later vsync, so the GS thread never waits. Measured
on the M2 by timing the readback directly rather than the frame, which keeps the
number clear of the gsrunner -perf perturbation: 7.2-7.8 ms becomes 0.005-0.008
ms. On a 45 ms frame that stall was around a fifth of the time.

It is not the blunt option. NoReadbacks and Unsynchronized, which this overlay
already ships for other titles, either skip the readback or race it. Async does
the real download and merely serves it a frame late, and it drops a late result
outright when an EE upload or a local-to-local move has since claimed those
pages, so stale data cannot overwrite newer contents.

Output is unchanged on every frame we can compare: four captured scenes, thirty
two colour frames, all pixel-identical, each scored against three baseline runs
of the unmodified binary first so run-to-run dump nondeterminism could not be
read as a result.

What that evidence cannot cover is motion. A frame-old occlusion probe differs
from a fresh one exactly when the probe's answer changes, which needs the camera
or the light to move, and every capture we hold is near-static. The visible
failure would be a lens flare blinking a frame late as it passes behind
scenery. That is the thing to watch for in play, and reverting is a one-line
change if anyone sees it.

Seven serials, every Rogue Galaxy entry the overlay carries, including the three
Japanese ones -- SCPS-15102, SCPS-17013 and SCPS-19254 are the same game under
its Japanese title.

OutRun 2006 is the only other title of the eight we profile that reads back at
all, three times a frame for 3.3-4.2 ms, and it is deliberately not included
here. Its readbacks are small colour buffers rather than depth, and there is an
open unexplained brightness bug in that game; feeding frame-old data into what
may be an adaptive-exposure loop is not something to do before understanding it.
2026-08-02 17:55:38 -07:00
bmdhacks fca3e9074b Correct the FpuMulHack comment: the constant is pi, not pi/2
0x40490fdb is 3.14159274, and 0.25 * that is pi/4 == 0x3f490fdb. The
comment called the multiplicand pi/2 and did not say what the patched
value 0x3f490fda is, which left it reading as an arbitrary magic number
lifted from x86.

It is not arbitrary: 0x3f490fda is pi/4 one ULP low, and one ULP low is
what the EE's multiplier returns here. Its Booth recoding drops one ULP
when ft's significand has an odd digit pair (ft & 0x2AA -- 0x490fdb & 0x2AA
== 0x28a, so it fires) and the exact product carries no tail below the
single ULP -- fs = 0.25 = 2^-2 has significand 2^23 exactly, so the product
is exact and the deficit reaches the result. The gamefix is a hardcoded
instance of a general defect, not a game-specific fudge.

Checked by executing the general widened model (cmtst/fmul/fcmeq/bic/add on
doubles under FZ|RZ) on both operand orders:

    model(0.25, pi) = 0x3f490fda    gamefix patches to 0x3f490fda
    model(pi, 0.25) = 0x3f490fdb    gamefix leaves alone (host value)

so the model agrees with the gamefix on the asymmetry too -- the predicate
reads ft's significand alone, and 0.25's is zero. That is the same
asymmetry the Cmp sequence below has, where s must be 0.25 and t must be pi.

Comment also records why the general model is not being pulled into this
fast path: here it costs ~9 instructions on every multiply in every game,
against 1 today. It belongs in iFPUd-arm64.cpp, where the operands are
already doubles and it costs 4 -- and where every eeClampMode:3 title gets
it, rather than the one title that needs the hack. Extending it to this
path needs its own measured case.

Comment-only change; no emitted code moves.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks c5a6c7cb0a Record the FPU's measured timings; leave the cycle table alone
The EE's divide unit was timed on silicon to bound what its recurrence can
be -- eleven rounds of modelling its rounding had never constrained the
hypothesis space, and cycles per quotient bit constrains it hard.

DIV.S and SQRT.S each occupy the FPU for 7 cycles and RSQRT.S for 13, against
this table's 6, 6 and 8.  MUL.S is latency 4 / issue interval 1, which is the
one entry the table already has right.  Measured with COP0 Count around loops
of k copies of one instruction so the loop overhead falls out as the intercept;
four runs, three byte-identical.

The interesting part is not the numbers but the mix.  Interleaved div.s/sqrt.s
costs the sum while div.s/add.s costs the divide alone, so div and sqrt are one
shared non-pipelined unit rather than two that happen to take equally long, and
RSQRT.S at 7+7-1 is two passes through it -- which is what FPU.cpp's RSQRT_S
already assumed from values alone.  The latency does not move on any operand
class, including denormals and divide by zero, so there is no early-out.

The table is not touched.  It drives game timing, its own comment already calls
itself a hack, and nothing here measured what moving it would do to
compatibility; that is a separate change with a separate burden of proof.  The
comment exists so the console run does not have to be taken a third time.

Idea by pstef.
2026-08-02 17:03:02 -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 48016d4ead Fix: an empty COP2 dest mask must still retire the MAC flag
cop2EmitFlagUpdate returned early on xyzw == 0, so a masked-to-nothing FMAC
left the PREVIOUS FMAC's MAC standing. It is not a silent op: every lane takes
VU_MACx_CLEAR, so MAC reads back 0 and the STATUS cause nibble empties while
the stickies stand.

Both references already agree. The interpreter runs the clear plus the STAT
update through applyBinaryMACOp -- _getDst returns &RDzero for fd == 0, it does
not skip the op -- and x86's REC_COP2_mVU0 has no such early-out at all,
reaching mVUupdateFlags with AND_XYZW == 0.

Console case VUSTICKY_EMPTY_DEST_MASK_SILENT; pinned by
VuStickyConsoleConformance.Arm64Cop2MacroEmptyDestMaskRetiresTheMacFlag.

This was previously bundled into the COP2 U/O flag work, which has been
reverted; the fix is independent of it and stands on its own.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 63f12f4009 Correct the COP2 FPCR comment: FZ is set in a default game
The comment 882c0d40ea left behind says "FPCR.FZ is NOT set while these
run". That was read off the test harness, which sets FPCR to 0 --
RecompilerTestEnvironment mirrors CPUThreadInitialize and stops before
the VM applies FPUFPCR. A real boot says otherwise. Printing FPCR from
recExecute before entering recompiled code, God of War II:

    FPCRPROBE live=0x1c00000 FPUFPCR=0x1c00000 VU0FPCR=0x1c00000 FZ=1

0x1c00000 is FZ plus RMode=ChopZero, from EmuConfig.Cpu.FPUFPCR, whose
default is DAZ+FTZ+ChopZero. On aarch64 DAZ and FTZ are the same bit, so
that flushes denormal operands and results alike.

No code change: the software flush stays, and the reason it stays is now
the accurate one. FZ is a per-unit user setting (EmuCore/CPU:
FPU/VU0/VU1.DenormalsAreZero), so it cannot be assumed either way; and it
could not supply the flag half regardless, since it erases the mantissa
that U is defined on. What the flush actually buys is that the two
engines agree in both environments -- redundant when FZ is on, load
bearing when it is not.

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
bmdhacks bcf55312d4 Fix: VU0 macro flag merge keeps the D/I cause and accumulates sticky D/I
Two mask defects in the COP2 macro flag merge, both against the console
and against the arm64 recompiler:

SYNCMSFLAGS preserved only 0xFC0, so every macro FMAC cleared the D/I
cause pair (0x30) that belongs to the div unit. VU_STAT_UPDATE assigns
statusflag outright, so those bits have to be carried from VI -- arm64
does the equivalent, keeping its denormalized bits 18-19 across
cop2EmitFlagUpdate, which EeVu0Cop2MacroLazyStatus.DivCurrentDIBitsSurviveFmac
already pinned.

SYNCFDIV preserved only 0x3CF, clearing sticky IS/DS and re-deriving them
from the current cause, so sticky D/I could never accumulate across two
div ops nor outlive a clean one.

Graduates eight interp rows from kMacroStatusDivergences:
VUSTICKY_DIV_DI_ACCUMULATE, VUSTICKY_FMAC_KEEPS_DI,
VUSTICKY_DI_ACCUMULATE_SQRT_DIV and VUSTICKY_DI_CAUSE_REPLACED_STICKY_KEPT,
slots 2 and 3 of each.

VUSTICKY_CLEAN_DIV_KEEPS_STICKY_DI stays recorded for both engines: its
slot-1 VRSQRT of -0 raises only D where hardware raises D and I, and
slots 2-3 are that missing bit carried forward -- a different defect that
happened to share the old reason text, now corrected.

Also drops VU_STAT_UPDATE's comment claiming it saves the sticky flags
and D/I settings; it assigns and saves neither, which is precisely why
the merge masks have to.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 1ea5e98e4b Fix: VU div-unit ops raise the sticky D/I bits, not just the cause bits
_vuDIV/_vuSQRT/_vuRSQRT set only the cause bits (STATUS 5:4) when they
raise D or I. VU->statusflag therefore never carried a sticky bit, so
the snapshot _vuFDIVAdd hands to the pipeline had nothing sticky in it
and _vuFDIVflush's `& 0xC30` contributed only the cause half -- the
interpreter's micro path set NO sticky D or I at any point. microVU
accumulates them and matches the console.

Sticky is derived from the cause bits the op just raised, which is the
same `<< 6` relation SYNCFDIV and the FMAC flush already use.

The macro path is unaffected: SYNCFDIV computes the sticky field from
the cause bits itself and masks these bits out.

Graduates VUSTICKY_MICRO_DIV_DI_ACCUMULATE (the always-on
MicroStatusMatchesConsole tripwire caught it, as intended).

Two neighbouring rows were re-diagnosed while confirming this and are
left recorded with corrected reasons -- the previous text named causes
the code does not have:

  VUSTICKY_MICRO_FSSET_CLEARS / _ASSIGNS_NOT_ORS are NOT an
  assign-vs-OR bug. _vuFSSET assigns the field, _vuRegsFSSET declares
  the REG_STATUS_FLAG write, and _vuFMACflush's REG_STATUS_FLAG arm
  assigns rather than ORs. Interp reads back 840 (the FSSET's sticky D
  plus a stale sticky Z) where the console gives 800. The real cause is
  recorded as an explicitly unverified hypothesis.

  VUSTICKY_MICRO_CLEAN_DIV_KEEPS_STICKY_DI is not the accumulation gap
  either; both engines read C00 as 800 because vrsqrt of -0 raises only
  D where hardware raises D and I.

DISABLED_AllMicroStatusMatchesConsole stays disabled: 9 -> 8 failures.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 2114367187 Fix: interpreter CTC2 narrows the integer VIs and masks STATUS
CTC2's default arm stored all 32 bits of the source GPR, so two cases
diverged from the console capture on the interpreter only:

  vi01-vi15 are 16-bit integer registers and read back as 0000FFFF after
  a write of FFFFFFFF; the interpreter kept the full word.

  vi16 STATUS has a writable sticky field of 0xFC0. The 0x3F current-flag
  field belongs to the FMAC pipeline and survives a CTC2, so a write of
  FFFFFFFF reads back as 00000FC0 with the live cause nibble intact.

Both recompilers already did this (microVU_Macro.inl recCTC2 emits a
16-bit store and the 0xFC0/0x3F merge; iCOP2-arm64.cpp recCOP2_CTC2
emits Strh and the same merge). The recompilers additionally broadcast
the denormalized STATUS into micro_statusflags; the interpreter does not
need to, because vu0ExecMicro re-copies VI[REG_STATUS_FLAG] into the
micro instances at program start -- COP2 cannot execute while a
microprogram is running, which is the comment's stated reason for doing
the copy there.

CLIP, I and Q still land in the default arm and stay 32-bit.

vi18/vi27/vi31 are left recorded in kCtc2Divergences: CLIP is 24-bit and
CMSAR0/CMSAR1 are 16-bit on silicon, and REG_CMSAR1 kicks VU1 without
storing at all. Those are wrong on BOTH engines, so
DISABLED_AllCtc2WriteMasksMatchConsole stays disabled -- its failure
count drops from 8 to 6.

The STATUS mask also graduates three interpreter rows in the sticky
suite, which is what "clears the sticky field and leaves the cause
nibble standing" means in practice.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks eff1a3b4b9 Fix: interpreter CFC1 implements the FCR alias and mask model
The interpreter's CFC1 was wrong three ways against the console capture:
it hardcoded 0x2E00 for FCR0 instead of reading fprc[0] (which holds
0x00002E30), it returned 0 for every index that is not 0 or 31, and it
handed back the raw FCR31 word with no fixed-bit model.

Hardware decodes only bit 4 of the register field: 0-15 alias FCR0 and
16-31 alias FCR31, with FCR31 reading back as
(fprc[31] & 0x0083C078) | 0x01000001. Both recompilers already did
exactly this (iFPU.cpp recCFC1, iFPU-arm64.cpp recCFC1), so this is the
shared interpreter catching up rather than a new model.

CTC1 is left as a raw store: both recompilers also store raw and apply
the model at read.

All 14 failures in
EeFpuFcrConsoleConformance.DISABLED_BothEnginesMatchConsoleFcrModel were
[interp]; the tripwire now passes and is enabled, and the divergence
table it fed is gone. The arm64 recCFC1 comment claiming the
interpreter returns the raw word is updated -- that is no longer true.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks 45b4197bee Fix: PMADDW/PMSUBW accumulate into one 64-bit value, not two 32-bit halves
Both the interpreter and the arm64 recompiler modelled each PMADDW/PMSUBW
lane as two independent 32-bit accumulations. That loses the carry between
LO and HI, needs a truncating `/ 0xFFFFFFFF` where an arithmetic shift
belongs, grew a +0x70000000 lane-0 addend to compensate, and leaves LO
sign-extended from the wrapped intermediate rather than from the
architectural result. The x86 recompiler has always done it correctly
(recPMADDW: PMULDQ + PADDQ + PMOVSXDQ against the packed accumulator), so
the three engines disagreed; this brings the other two into line with it.

Scored against the 64 ps2autotests PMADDW/PMSUBW captures, which preset
HI/LO to nonzero values and so make the width of the accumulate observable:

  two 32-bit halves + errata          54/64
  packed 64-bit accumulate            64/64

The errata components are not separable. Reintroducing any one of them
alone into the corrected model scores far worse than leaving all three in:

  packed + voodoo addend              52/64
  packed + truncating divide           3/64
  packed + carry dropped from HI      33/64

The truncating divide's off-by-one very nearly cancels the missing carry
on these operands, which is why the shipping code only lost 10 cases. A
partial fix here would have been much worse than none.

On arm64 the correct form is also the smaller one: Smull plus a 64-bit
add against the spliced accumulator replaces the lane splitting, the SDIV
by 0xFFFFFFFF and the whole conditional-addend block, and Rd.UD[dd] turns
out to be the raw result. Verified bidirectionally: reverting either
engine alone leaves 7 tests failing, since h.Run() auto-diffs the JIT
against the interpreter.

Tests: the six EeRecMmi vectors that pinned the errata are rewritten to
pin the carry, the borrow, the per-half sign extension and the absence of
the lane-0 addend; all six fail on the unpatched baseline. The
PMADDW/PMSUBW allowance in EeMmiConsoleConformance is deleted, so all 64
cases now assert normally.

Also corrects a comment in iMMI-arm64.cpp claiming MMI2_RECOMPILE is
never defined -- Config.h:1650 defines it unconditionally, which makes
that part of pcsx2/x86/iMMI.cpp the shipping x86 implementation rather
than dead reference code.

Idea by pstef.
2026-08-02 17:03:02 -07:00
bmdhacks e13cfa6311 Fix: interpreter LD must not write the zero register
LD wrote cpuRegs.GPR.r[_Rt_].UD[0] unconditionally, so `ld $0, ...`
followed by a read of $0 returned the loaded doubleword instead of zero.
All eleven of its neighbours already guard: LB/LBU/LH/LHU/LW/LWU/LWL/LWR/
LDL/LDR early-out on !_Rt_, and LQ routes through gpr_GetWritePtr, whose
reason for existing is the dummy zero slot. Both recompilers were already
correct.

Shape matches LW/LDL/LDR: perform the read first so its side effects
still happen, then drop the result if the destination is $0.

EeLsuConsoleConformance.DISABLED_AllLoadsMatchConsole recorded this as
the one divergence from the console capture; the case now passes on both
engines, so the divergence table is gone and the always-on
LoadsMatchConsole scores every case against silicon directly.

Idea by pstef.
2026-08-02 17:03:02 -07:00
Brian Degenhardt af4079edea Merge pull request #519 from ARMSX2/aethersx2-savestates
Load AetherSX2/NetherSX2 save states
2026-08-02 16:58:58 -07:00
Brian Degenhardt 173d10bf0c GS: never wait for the back queue to empty from the EE thread
The back queue's empty-wait admits a single waiter. The worker posts
m_empty_sema exactly once on its transition to idle, clears the waiting
flag, and sleeps; a second waiter that armed the same flag blocks forever,
because nothing will post again. WaitForEmpty has a pxAssertMsg saying so,
but it is compiled out in Release, so on a handheld build this is a silent
hang rather than a message.

ReadLocalMemoryUnsync was the second waiter. It runs on the EE thread, and
it drained the back queue on entry. Under the HW download modes that read
local memory from the EE thread — Unsynchronized and Asynchronous — every
readback arms both waiters by construction: the EE thread drains here while
the MTGS thread is already waiting, either in the lockstep tail of
PushRecord or in InitReadFIFO servicing the very AsyncReadFIFO packet this
function queues. A readback-heavy scene therefore wedges within seconds,
and resuming a savestate into one wedges immediately.

Drop the drain. Nothing depended on it. The Asynchronous path reads the
shadow copy under m_async_readback_mutex, which is the actual
synchronization point and is published by the GS thread — a drain adds
nothing there, and stalls the EE thread on the GS thread, which is what the
mode exists to avoid. The Unsynchronized path races the renderer's local
memory writes by definition, and a drain narrows that window without
closing it, since MTGS can queue another record the instant it returns.
Staleness is already bounded the real way, in
OpenGSRenderer: the pipelined front-object split is refused outright
whenever HW downloads are read from the EE thread.

The queue now also records which thread claimed the empty-wait, and drains
from any other thread assert. WaitForEmpty's own guard only trips when two
waits happen to overlap; this one trips on the first off-thread drain
whatever the timing, so the next one to be added fails loudly in Devel
instead of hanging in Release.

Only back-thread modes Lockstep and Pipelined ever started a consumer, so
only those two combined with the two EE-thread download modes were
affected. With the back thread off — the default — the removed call was
already a no-op.

Verified with pcsx2-gsrunner replaying an OutRun 2006 water-scene dump:
the repro aborted on the assert within five loops before, and now runs
twenty loops and 312 readbacks clean. All twelve back-thread x download
mode combinations pass, and frame dumps are byte-identical between
back-thread-off and pipelined-with-async.
2026-08-02 16:39:50 -07:00
Brian Degenhardt c2ea1c1f24 SPU2: restore the full core state from AetherSX2-era savestates
The first cut reset the cores and brought back only RAM, registers and
the mixer scalars, and mapped the era's volume levels across raw. Both
halves were wrong. Volume levels are 31-bit in these eras and 15-bit
today, so a full-volume legacy level overflows the mixer's s32 volume
multiply and turns the whole mix into broadband static — loud enough to
amplify even a paused game's silence into noise, which is what made the
defect look independent of the SPU2 payload. And the dropped voice,
streaming and DMA state belongs to a transfer the restored IOP driver
still believes is in flight: left at reset, the driver waits forever
for a completion interrupt no counter will deliver.

Map everything instead: voices (envelope levels scaled 31->15 bits, the
era's Releasing flag folded into the phase exactly as its Calculate()
folded it, decode restarting at the current block), the ADMA/DMA
counters (host pointers left null for the engine's own savestate heal,
except the read-path pointer, which the heal misses — rebuilt from the
channel TADR register), and the SPDIF/ring-position/playmode tail.

Now that this is a field-by-field mapping rather than a memory copy, it
moves out of spu2freeze.cpp into its own translation unit, leaving that
file byte-identical to upstream's. The era layout tables are ours to
carry and upstream keeps editing that file, so the two are better apart.

Verified headless against graded audio captures: a mid-gameplay 0x9A2C
resume state (Dragon Quest VIII) comes back with its music grading
music-like (spectral flatness 0.007) where the partial restore graded
noise (0.38+), and pause-menu saves come back silent because their
sound driver parks every voice at zero pitch and volume until unpaused.
92 core and 1714 recompiler tests green.
2026-08-02 11:20:44 -07:00
Brian Degenhardt 21d1bfcf97 SaveState: don't reject a legacy state when the VM has no ELF CRC yet
Loading a state at startup resets the VM first, and the reset clears the ELF
CRC, so by the time the legacy reader checks identity the running game's CRC is
0. Comparing against it rejected every state booted with -statefile:

  This AetherSX2 save state is for CRC 45FE0CC4, but the running game is CRC
  00000000.

An unknown CRC is not a mismatch. Only compare when the VM actually has one,
which is the same rule the rest of the emulator uses (ReportGameChangeToHost
reports a CRC only once HasBootedELF()). The disc serial, which survives the
reset because it comes from the disc rather than the ELF, still hard-fails on a
wrong game — that gate is unchanged.

Ratchet & Clank: Up Your Arsenal now resumes from both legacy formats: the
NetherSX2 0x9A34 state and the AetherSX2-era 0x9A2C one, the latter exercising
all three of that era's deviations. Both consume the blob exactly, and the EE,
IOP and GS all run on from the restored state.
2026-08-02 11:19:56 -07:00
Brian Degenhardt 0857b2647c SaveState: test legacy cycle widening, and say when a state was imported
The one piece of real arithmetic in the legacy reader is cycle widening: the
old formats count in 32 bits and wrap about every 14.6 seconds of emulated
time, so a counter has to be widened by its signed distance to its domain's
own cycle count, not zero-extended. Getting that wrong reschedules an overdue
event billions of cycles into the future, which is the kind of bug that
presents as "the game just hangs sometimes". Cover both wrap directions.

Also tell the player when a state came in through this path, since nothing
writes the format any more: saving again is what converts it, and the audio
and controller state we could not carry over is better said than discovered.
2026-08-02 11:19:56 -07:00
Brian Degenhardt 078b5d661b SaveState: load AetherSX2/NetherSX2-format save states
Accept the two legacy majors at the version gate and route them to the legacy
reader, so states from AetherSX2 v1.5-era builds and NetherSX2 v2.1 load in
place. Re-saving one writes a current-format state, which is the whole of the
conversion story.

Three of the zip entries need era-specific handling, so entries now declare how
they survive a legacy load:

- GS carries its own version, which Defrost still reads back through these
  eras. Only its size has to come from the zip entry rather than from today's
  component, since the generic reader would otherwise demand a current-sized
  block and reject a short read.
- SPU2 shares only its head (registers and sample RAM) across eras; its tail
  was re-laid-out repeatedly while the block's self-version stayed at 0xe, so
  the self-version cannot arbitrate it and ThawIt must never see one. The new
  SPU2freezeLegacy restores the memory and resets the cores instead, which
  costs a note in flight and nothing else.
- PAD, USB and achievements predate the StateWrapper streams that read them
  today. They are skipped, and correspondingly not required to be present: the
  pads keep the type and mode they booted with.

The GS entry's existing overrides gain the `override` keyword, which adding one
to the class now requires.
2026-08-02 11:19:56 -07:00
Brian Degenhardt 08dddb0947 SaveState: add a reader for the legacy 0x9A2C/0x9A34 blob layouts
Savestates written by the AetherSX2/NetherSX2 era of upstream PCSX2 use two
internal-structures layouts that today's reader cannot parse: 0x9A2C (upstream
0312e902) and 0x9A34 (upstream 7e939b75). Add a load-only deserializer that
consumes those layouts field-by-field into live emulator state.

Old struct layouts are declared in an OldState namespace, each cited to the
upstream sha it was taken from and guarded by a static_assert of the byte-exact
size. A second assert group pins the sizes of the CURRENT types the legacy
layout depends on, so upstream drift breaks the build rather than desyncing a
load mid-blob. Blocks that are byte-identical across eras reuse the existing
freeze functions.

The two era gaps that need real work: 32-bit cycle counters widen relative to
their domain base so wrap-straddling deltas survive, and fields that moved
between the cpuRegs and Cycles blocks at 0x9A31 are staged and committed once,
after the disc identity check, so a wrong-game state cannot half-apply.

Not yet wired into the load path; that follows.
2026-08-02 11:19:56 -07:00
Brian Degenhardt 5bf499b21d SaveState: plumb the on-disk savestate version into the load path
CheckVersion now reports the version word it read, and
LoadInternalStructuresState forwards it into SaveStateBase::SetVersion,
so freeze readers can see the actual version of the file being loaded
instead of assuming g_SaveVersion. No behavior change: the version
gate itself is unchanged, and nothing consults the plumbed value yet.

Groundwork for loading legacy-format (AetherSX2-era) savestates.
2026-08-02 11:19:55 -07:00
Brian Degenhardt 9f957a54d4 CI: install the Rust Android target in the nightly Android job
The nightly Android APK job has been failing since at least 2026-07-27
with "error[E0463]: can't find crate for `core`" at the librashader
cargo step: the runner's rustup ships only the host std, so the
cross-compile to aarch64-linux-android has no core to link against.

build-all.yml already carries this step (10fab243e7), but nightly.yml
has its own separate dual-core + PGO Android job that never got it.
Because that job is continue-on-error, the workflow kept reporting
success and the nightly release simply published without an APK.
2026-08-01 21:15:45 -07:00
Brian Degenhardt 1a5fc1c373 GS: record how a self-reading draw was resolved in the per-draw ledger
The ledger's tex_hazard and barrier columns are the draw config after
HandleTextureHazards has already rewritten it. A draw that arrived with its
texture aliasing the render target and was resolved by copying the target reads
back as tex_hazard NONE, barrier 0 -- indistinguishable from an ordinary
textured draw, with the copy nowhere in the table. Reading that resolved state
as the original state is how a previous investigation concluded the copies came
from texture-cache invalidation when they come from hazard handling.

So record the road taken, on a new self_read column: TEX_IS_FB, BARRIER,
DEPTH_DIRECT or COPY, blank when the source did not alias the target at all.
It is set pessimistically at the top of hazard handling and corrected by each
exit that avoids the copy, because the function has too many early returns for
a single assignment to cover.

This is what the copies actually cost, and it is not visible anywhere else:
Rogue Galaxy's church scene records 67 COPY draws per frame at autoFlush 2
against 2 at autoFlush 0, and they fall in exactly two runs of consecutive
draws per frame, 11 and 56 long, each run writing one render target.
2026-07-30 21:55:58 -07:00
Brian Degenhardt 17b2be058a GS: record a complete dump under the pipelined back-thread split
The dump's transfer and ReadFIFO hooks sit on the parse path, and its initial
state came from Freeze() on the renderer. Under GSBackThreadMode=Pipelined the
parse path belongs to the front object, so both were reading the wrong object:
the front's transfers never reached the dump at all. A Rogue Galaxy capture that
should be 39.4 MB of packets came out with 90 KB -- 0.2% of the stream, the
ReadFIFO and VSync packets alone -- and replayed as nothing. GSQueueSnapshot
warned about it rather than fixing it (GV7-2).

The dump stays owned by the renderer, which opens and closes it on the present
path; the parse side reaches it through GetDumpSink(), which routes via
m_mem_target, and the initial freeze goes through a new m_parse_target, the
inverse pointer. Both paths run on the MTGS thread -- the front's runahead is
over the back thread, not over the thread handling vsync -- so the front writes
straight into the back's dump with no synchronisation. m_parse_target->Freeze()
is the same call GSfreeze makes for a savestate, which already drains and
already takes registers from the front and local memory from the back.

Verified on a Rogue Galaxy savestate, frame-stepped over PINE so both arms start
from the identical state: the mode 3 dump is byte-identical to the mode 0 dump,
4.2 MB of initial state and 39.4 MB of packets, and it replays in gsrunner to
ten frames identical under both modes. Two mode 0 runs are likewise identical,
so the harness has no slack. Reverting just the transfer sink reproduces the
90 KB dump, so the comparison has teeth.

Two bytes of bookkeeping ride along: GSQueueSnapshot loses the warning, and the
MsgGSDump reply loses pipelined_incomplete, which now has nothing to report.
Whether the split engaged is a genuine question, so it moves to the stats reply
as gs_front_parser, next to gs_back_thread_pct where it belongs.
2026-07-30 21:55:58 -07:00
Brian Degenhardt 7f93a80dd7 PerformanceMetrics: count the GS back thread
Under GSBackThreadMode >= Lockstep roughly half the GS work moves to a second
thread, and every surface that reports GS cost -- OSD, PerfLog, the Qt status
bar, PINE stats, gsrunner's @HWSTAT@ block -- measured the MTGS thread alone.
So the split read as a large GS saving. It is not: on a Rogue Galaxy savestate
here, mode 0 costs 15.8% / 2.63 ms and mode 3 costs 17.0% / 2.84 ms plus
14.2% / 2.37 ms on the back thread -- about twice the total GS CPU time, bought
to halve the critical path. That is a real trade, but nobody could see it.

The back thread registers its own handle at entry, as the SW rasterizer workers
do; StopBackThread clears it after the join. Unlike every other handle here it
is written by a thread other than the one sampling it, so the handle and its
running total sit behind a mutex taken twice a second. Installing a handle
rebases the total off it, so the first window after a GSreopen respawn measures
the new thread rather than its difference against the retired one's.

The figure is omitted, not reported as zero, wherever a back thread does not
exist -- otherwise a mode 0 vs mode 3 comparison reads a permanent 0% as
meaningful. gsrunner latches the presence flag during the run because DumpStats
executes after VMManager::Shutdown, by which point the thread has joined.
2026-07-30 21:55:58 -07:00
Brian Degenhardt 975e408ed5 PINE: add a GS-dump opcode so a script can capture without a hotkey
MsgGSDump (0x14, ARMSX2-local) queues a GS dump of the next N frames:
[u32 frames][u32 path_len][path bytes], where frames == 0 stops a recording
dump and UINT32_MAX records until stopped -- the same press/release pair the
GSDumpMultiFrame hotkey binds. The reply is JSON carrying the resolved dump
path, so a client knows the file to wait for instead of guessing at the
snapshots folder's auto-naming.

Three things the naive version of this gets wrong, all found by testing it
against a live Dragon Quest VIII:

QueueSnapshot honours a caller-supplied path only when it ends in .png, and
silently substitutes an auto-named file otherwise -- a scripted client would
write somewhere it never looks. Normalise the path up front instead, dropping
a .gs/.gs.xz/.gs.zst/.png suffix if the caller spelled one out so that naming
the file you want does not earn a doubled extension.

A request that arrives while a dump is already recording creates no second
dump: the VSync handler only opens one when none exists. It writes a stray
screenshot, and worse, overwrites the running dump's remaining frame count and
cuts it short. The first version of this replied with a path for a file that
was never created and truncated the recording that was. Refuse instead, with
reason "already recording"; the caller can stop the running dump first. The
same defect reachable via the Screenshot hotkey is left alone here -- it is a
renderer behaviour change and belongs in its own commit.

The PINE thread cannot push MTGS packets: the ring is single-producer and that
producer is the EE thread. Take the same two-hop route BuildStatsJson already
documents -- Host::RunOnCPUThread, then RunOnGSThread -- and read GSConfig's
compression method on the GS thread, since it decides the extension.

QueueSnapshot and GSQueueSnapshot now return whether they took the request;
existing callers ignore it. GSIsDumpRecording and GSHasFrontParser expose the
two pieces of GS-thread state the reply needs. pipelined_incomplete surfaces
the known GV7-2 gap rather than letting a script collect corrupt dumps.

Verified live: every promised path was written, refusals produced no files,
and all three dump shapes replay in gsrunner -- single-frame as 4 (2) frames,
a stopped multi-frame recording as 186 (91).
2026-07-30 21:55:58 -07:00
Brian Degenhardt bf65e8604b GameDB: drop autoFlush on Rogue Galaxy — a deliberate speed/accuracy trade
Rogue Galaxy is the slowest title we track on handhelds and users report it as
such. Turning autoFlush off is the largest lever we have found for it: render
passes -38%, texture copies -74%. On the Adreno 610, which has no headroom, that
is -1.82 ms/frame and +2.6 fps. On the Adreno 650 it is -1.67 ms banked as
headroom, both arms already at 100% speed.

This is not free and should not be recorded as if it were. The software
renderer, an exact per-pixel GS model and an independent oracle here because
AutoFlushSW is a separate setting, scores level 0 3.4x further from truth than
level 2 on the contested pixels (mean error 9.436 vs 2.808; level 2 is closer on
17312 of 22424). What degrades is the light a lamp contributes to nearby lit
surfaces, so chests, blades and floors read slightly bright and warm. The glow
cones themselves are pixel-identical.

It is taken because the error is imperceptible in practice: bounded at 21-23/255
in all three captured scenes, diffuse rather than a missing object, and four
independent side-by-side looks at 1:1 failed to distinguish the two. Revert to
level 1 -- not 2 -- if anyone reports a regression: level 1 is pixel- and
cost-identical to 2 on Rogue Galaxy at 1x, 3x and 6x, and since 381bc41ded it is
also worth -5.8% of GS-thread cycles because it moves the game's non-sprite prims
onto the direct vertex kick. Level 2 buys nothing measurable over level 1 here.

Seven serials, which is every Rogue Galaxy entry the overlay carries. The Korean
release is SCKA-30005 and upstream gives it no gsHWFixes at all, so it is absent
here too rather than newly missed.

⚠ SLKA-25372 is Black, not Rogue Galaxy -- it is Criterion's Burnout engine,
which is why it carries OI_BurnoutGames. An earlier working copy had it in the
Rogue Galaxy set and flipped it to 0; it stays at 2.
2026-07-30 21:55:58 -07:00
Brian Degenhardt f0aa0f1949 GS: take the direct vertex kick for non-sprite prims at autoflush SpritesOnly
The auto_flush instantiations of the vertex handlers exist to feed
HandleAutoFlush, which reads the incoming vertex out of m_v. To do that they
stage every vertex through m_v instead of keeping it in registers, which is why
SetPrimHandlers hands the same auto_flush argument to every primitive type.

At SpritesOnly that is wasted on everything that is not a sprite. IsAutoFlushDraw
early-outs on the prim before it looks at anything else, so those prims write a
staged vertex, read it once, and discard it. Narrow the template argument per
prim so they take the fused direct kick instead, mirroring IsAutoFlushDraw's
early-out exactly -- it keys on the level alone and not on the renderer, so the
software path narrows in step.

Dragon Quest VIII renders identically at levels 1 and 2 (same draws, passes and
copies), so level 2 is an exact staged control for level 1's direct path with no
rendering difference to confound it. GS-thread cycles over 3 runs each, 240
frames: 2043.2M staged against 1947.4M direct, ranges disjoint, -4.7%. The parse
handler itself goes 272.5M -> 156.2M, so it accounts for essentially the whole
delta. Rebuilding the old handler table and diffing against it agrees: -5.1%.

Output is unchanged, as it must be: prims, draws, render passes and copies are
identical on Dragon Quest VIII and Rogue Galaxy, and all four dumped frames are
pixel-identical under both the hardware and the software renderer.

581 GameDB entries ship autoFlush: 1.
2026-07-30 21:55:58 -07:00
Brian Degenhardt 80e4d09b99 GS/VK: arm the mid-frame submit kick in frames, not render passes
The kick's arming window exists to answer one question -- has this game read
back recently enough to be worth kicking for -- so that titles which never read
back see zero change. It counted render passes, and 128 passes means completely
different things in different titles: about three frames of OutRun 2006, but
only about three quarters of a Rogue Galaxy frame. So RG armed the window at its
one readback per frame, spent it partway through, and then ran the rest of every
frame with the kick silently switched off. Nothing asked for that; it fell out of
the unit.

Count the window in frames since the last readback instead, which is the unit the
comment already claimed ("~a few frames' worth of render passes") and the unit the
decision is actually about. The cadence stays in render passes, where a uniform
interval is what you want. The never-read-back guarantee is unchanged and still
carried by the ~0u sentinel.

Measured on M2/Honeykrisp, 60-90 frames per dump, gsrunner without -perf: total
GPU stall (readback wait plus command-buffer activate stall) is unmoved --
Rogue Galaxy 554ms before and 558ms after, OutRun 2006 320ms and 319ms, both
inside run-to-run spread. Shadow of the Colossus and Black, which never read
back, take zero kicks before and after. So this is not a speed change here; it
removes a scene-dependent cliff that a device where the kick matters more could
land on.

While measuring, the threshold's cost model turned out to be badly wrong, so
correct the comment. "RPs-per-frame / threshold extra submits" predicts ~14
kicks/frame for Rogue Galaxy; the real figure is 2, because the fence gate -- not
the threshold -- is what binds. With three command buffers only two submissions
can be in flight, and ~3300 of ~3400 offers to kick find the next command buffer
still executing. Sweeping the threshold 8->16 measured -2% stall on Rogue Galaxy
and +12% on OutRun 2006, so it is left alone.
2026-07-30 21:55:58 -07:00
Brian Degenhardt 639b317dbc gsrunner: stop the Wayland message pump blocking past the shutdown flag
The pump polls the display fd with a 16 ms cap so it can re-test the shutdown
flag between polls, but on POLLIN it called wl_display_dispatch(), which reads
the queued events and then waits for more. A window nobody is drawing to gets
no further events, so the flag was never re-tested and the process never exited
-- gsrunner would print its whole stats block and then hang forever, leaving
every automated run to be killed by a timeout.

Switch to the non-blocking read sequence: prepare_read, flush, poll, then
read_events or cancel_read, then dispatch_pending. Nothing in the loop can
block now, so the cap does what its comment claims.
2026-07-30 21:55:58 -07:00
Brian Degenhardt b2d7a8d0a6 GS: serve 1:1 same-format StretchRects as image copies
A StretchRect is a draw, so it needs a render pass of its own and the pass it
interrupted has to be restarted afterwards -- two pass boundaries. When the
stretch is really a plain 1:1 copy between identically-formatted textures, the
backend's image-copy path does the same work for one.

The texture cache hits this constantly. A target-backed source is destroyed
outright whenever anything writes its target, so every autoFlush split
re-copies the sampled region of the render target it has just written.

The gate is narrow enough that the two paths cannot disagree on any pixel:
plain COPY/DEPTH_COPY with a full write mask, identical formats, depth-vs-colour
aspect agreeing on both sides, a source that actually holds contents rather
than a pending clear, rects that land on the texel grid at 1:1, and both rects
in bounds -- the draw path scissors an out-of-range destination and edge-clamps
out-of-range source coordinates, and a copy can do neither.

Render passes over a 5-loop gsrunner replay, Vulkan / OpenGL:

  Rogue Galaxy   2411 -> 1741  /  2283 -> 1373
  OutRun 2006    1240 -> 1118  /   811 ->  657
  Black          1080 -> 1010  /   282 ->  202
  God of War II  1278 -> 1238

Draw counts are unchanged everywhere. Colour output is bit-identical on Vulkan
across all six staged dumps at 1x and 3x, and on OpenGL for the dumps that
render deterministically there.
2026-07-30 21:55:58 -07:00
Brian Degenhardt 365c0e2eaf Merge pull request #402 from ARMSX2/help-menu-and-branding-fixes
Fix Help menu and rebrand user-facing PCSX2 references to ARMSX2
2026-07-29 20:47:08 -07:00
Brian Degenhardt a8c5521c66 GameDB: Rogue Galaxy no longer preloads frame data
preloadFrameData primes every newly created render target from the game's
GS local memory. Where the frame's geometry doesn't cover the target, those
preloaded pixels stay visible: in the church interior the outdoor town shows
through the rear wall, washed out and semi-transparent. Bisected on a
Snapdragon 865 with the other six fixes held either way — the artifact
tracks preloadFrameData alone, and the three alignment fixes are innocent.

Upstream added it in f5570b7f40 next to roundSprite, and that commit
message justifies only roundSprite; the preload line came with no stated
reason beyond "Fixes corrupt textures especially on water". Dragon Quest
VIII and Dark Cloud 2 run the same Level-5 engine and share this title's
roundSprite, halfPixelOffset and nativeScaling values, but neither enables
the preload — both handle their water and sprite errors with CPU sprite
rendering. So if the water corruption does resurface, that targeted pair is
the replacement rather than this.

Dropped from all seven serials. The remaining six fixes are restated because
an override replaces the fixes map wholesale; names, compat and kozarovv's
out-of-bounds patches still inherit from bin.
2026-07-29 20:28:41 -07:00
Brian Degenhardt 355a1a8739 tests: reset GIF PATH1 per replay — escaped wrap-head bytes filled the ring
The vurunner/VuReplay PATH1 sink covers Gif_Unit::TransferGSPacketData, but
microVU's XGKICK wrap path sends the pre-wrap head through
Gif_Path::CopyGSPacketData directly (the same harness blind spot pstef found
landing the console XGKICK cases). Those bytes land in the REAL gifPath[1]
ring, which nothing drains in a runner with no GS thread: across a few
hundred wrapped-kick captures in one process the ring fills,
CopyGSPacketData calls mtgsReadWait, and MTGS::WaitGS trips its devel
closed-thread assert — aborting corpus sweeps mid-batch (release would
early-return instead and lose the wait). Backtrace: mVU_XGKICK_ →
CopyGSPacketData → mtgsReadWait → WaitGS, cap ~360 of a 400-cap batch.

Reset gifPath[1] at each replay entry so escaped bytes can never accumulate
across captures. Still correct once the sink covers both entry points —
then it's just belt-and-suspenders. The 400-cap batch that aborted now
completes; suite stays green.
2026-07-29 20:28:41 -07:00
Brian Degenhardt efeab3d35b tests: seed the E-bit delay slot in SeedVu0Microprogram
Architectural E-bit cleanup executes one more pair after the E-bit pair.
VuTestHarness::LoadProgram has always appended a NOP pair for that delay
slot, but EeRecTestHarness::SeedVu0Microprogram — the path the EeVu0Vcallms
tests seed through — did not. VU0 micro mem is shared, never-reset global
state, so the unseeded delay slot executed whatever pair a previous test
left there: at one --gtest_shuffle ordering the Vu0SpecialBits T-bit branch
programs leave 'vi3 = 0x333' at pair 2, and both engines faithfully ran it
right after the victim's own program wrote vi3 — agreeing with each other,
so only the expected-value assertions caught it (seed-2 EeVu0Vcallms pair).

Mirror LoadProgram: when the caller's final pair carries the E bit, write a
NOP pair into the delay slot too. Verified 60/60 shuffle seeds green.
2026-07-29 20:28:41 -07:00
Brian Degenhardt 9b1f9992b8 IOP: collapse RAM mirrors in the HWADDR domain — cross-alias SMC ran stale code
The recLUT shares BASEBLOCK slots across the four RAM mirrors (guest page i
maps physical page i & 0x1f in the 2MB config), so a block compiled at one
alias stays dispatchable through every other — but psxhwLUT only stripped
the segment base, leaving block registration, coverage, recBlocks and the
clear-path range guard keyed by the un-collapsed address. A store through a
different alias of a compiled page then missed every invalidation structure
while the shared slot kept executing the stale block. Found by
--gtest_shuffle: IopIrxHle leaves a block at canonical 0x14000, and the
RAM-mirror SMC test then loads its victim program through 0x214000 — the
C-path clear missed the stale block and the JIT ran the IRX test's code.

Collapse the whole domain instead: the psxhwLUT entries for the RAM window
fold the mirror bits (identity in the 8MB config), recClearIOP
canonicalizes caller addresses up front so the g_psxMaxRecMem guard and
everything HWADDR-keyed below agree, g_psxMaxRecMem itself tracks
HWADDR(psxpc), and the store stub probes the collapsed offset it already
computed for the store. Cross-alias SMC is pinned in iop_smc_tests.cpp in
both orientations plus the JIT store-stub path.
2026-07-29 20:28:41 -07:00
Brian Degenhardt 5d4184c2c1 tests: pin the VU0 run-ahead floor divergence, reset inherited VU0 control state
With VU0 left running and fewer than 16 cycles from its E-bit, a following
non-interlocked COP2 transfer legitimately diverges JIT-vs-interp: interp
transfer ops sync exactly (vu0Sync, no floor) while both recompilers floor
the grant at 16 cycles (vu0SyncRunAheadThin / x86 CalculateMinRunCycles),
so the JIT drains the leftover program where interp leaves it in flight.
Pin that window per-engine in ee_vu0_runahead_floor_tests.cpp with
deliberately constructed running state, alongside the two convergent cases
(interlocked access, delta >= remaining).

EnableVu0Capture now resets the VU0 control state that used to inherit
from the previous test (VI[24..31], flags, cycle, interp resume sentinels)
— the source of the order-dependent EeVu0* shuffle failures recorded
2026-07-25. Verified across 40 shuffle seeds: the inheritance class is
gone. The remaining IopSmc and seed-2 EeVu0Vcallms shuffle failures
reproduce without this change and are tracked separately.
2026-07-29 20:28:41 -07:00
Brian Degenhardt 9fbb2d8cf2 GS: size the draw-staging arrays independently of the vertex buffers
GrowVertexBuffer listed m_draw_vertex/m_draw_index alongside the real vertex
and index buffers and preserved their contents across the reallocation,
copying sizeof(GSVertex) * m_vertex->tail bytes out of them. That length has
no relationship to their allocation: the staging arrays are single per-object
buffers sized by whichever growth happened to run last, while m_vertex and
m_index point at a rotating set of independently sized draw slots and pooled
draw-node arrays whose capacities are exchanged thousands of times a second.
Two numbers maintained by unrelated mechanisms, assumed to track each other.

God of War II crashed on Android 2.6.6 with SIGSEGV inside memcpy on the MTGS
thread, in the GIF parse path, on exactly that copy: the buffer whose tail was
read had grown to ~50k vertices while the staging array was still the 10k one
from init, so the copy ran ~1.1MB past the end. Instrumenting the same scene
from a savestate reproduces the mismatch locally at 49108 live vertices
against a 10000-vertex staging array (1.19MB), plus 85398 indices against
60000. The over-read only faults where the heap layout puts an unmapped page
in range, which is why it hit a tester and not the dev box.

The staging arrays are write-then-consume: SetupIA overwrites the full range
it stages before anything reads it back, so their contents are dead at growth
time and never needed preserving. Drop them from GrowVertexBuffer and give
them their own grow-only capacity, established at the point of use from what
is actually being staged. That also closes the matching out-of-bounds write on
channel-shuffle draws, and removes two dead allocations plus two large dead
memcpys from every buffer growth.

Rendering is unchanged: per-draw ledgers over two God of War II dumps are
byte-identical before and after.

gs_draw_staging_tests pins both properties -- growth must not touch the
staging arrays, and staging capacity covers the request and never shrinks.
Re-listing the arrays in GrowVertexBuffer turns the first test red, and under
-DUSE_ASAN=ON it reports the original fault outright: heap-buffer-overflow,
READ of size 319904, 0 bytes after a 128000-byte region, in
GSState::GrowVertexBuffer.
2026-07-29 15:43:56 -07:00
Brian Degenhardt 7abd063dc7 COP2: pre-clamp Fs on the MADDA broadcast row
NASCAR Thunder 2002 drew every car as a shredded wireframe under the EE
recompiler; the EE interpreter drew them correctly, and VU0, VU1 and IOP
were all identical to the JIT, so the fault was EE-side COP2 macro code.

COP2_MADDA_BC multiplied Fs straight from its register. x86 specifies cFs
for mVU_MADDAx/y/z/w, and the reason is that the PS2 VU has no infinities:
an exponent-FF word is an ordinary large number, so against a zero
broadcast lane the architectural answer is clamped(Fs) * 0 = 0. Taken
unclamped it is the host's Inf * 0 = NaN, which the post-op result clamp
then folds to +/-FLT_MAX. A transform accumulating that into ACC scatters
the geometry it was positioning.

The pre-clamp mirrors COP2_MADD_BC's existing clampFs. MSUBAx/y/z/w pass
false: x86 gives them clampType 0, so their unclamped Fs is a shared,
by-design divergence, not an arm64 defect.

The game symptom needed the whole (lane x dest mask) grid to be right as
well, so the test sweeps that for both halves of the family before pinning
the clamp corner. recompiler_tests 1706/1706.
2026-07-28 21:01:13 -07:00
Brian Degenhardt 06445dd641 eerunner: narrow --rec-fallback to a single VU macro op
Once a hunt reaches `cop2vu` it stops: that group is one dispatch bit
covering the whole VU macro-mode instruction set, and there was no next
axis. Finding which of them miscompiles meant hand-editing the classifier
and rebuilding per hypothesis.

Two additions close that gap. `--rec-fallback cop2vu:<mnemonic>` selects
individual macro ops by name, over a flat 256-entry id space covering all
three dispatch tables (BC2 by rt, SPECIAL1 by funct, SPECIAL2 by its
packed index). And a compile-time census, printed after --mkstate, lists
the macro ops the run actually emitted — an op that never compiles cannot
be the bug, so it turns a 100-way search into a bisect over the handful a
given game really uses.

On NASCAR Thunder 2002 the census reported 58 distinct ops and the bisect
reached one of them in eleven runs, no rebuilds.
2026-07-28 21:01:12 -07:00
Brian Degenhardt a88b4aa89c tests: cover every write-lane subset of the masked VIF unpack store
A masked unpack does not store its quadword with one instruction. doMaskWrite
picks, from a sixteen-way switch, a hand-written sequence touching only the
lanes that cycle actually writes, and those sequences differ in kind rather
than just in offset: a 64-bit store for X+Y, a 64-bit lane store for Z+W,
per-lane stores at hand-computed byte offsets for the scattered subsets, and a
post-indexed pair for Y+Z. Each is its own chance to name the wrong lane.

Only the three-lane subsets were reached. Measured, not assumed: of the sixteen
cases, 7/11/13/14 executed and the other twelve had zero counts, because the
existing mixed-mask cases happen to protect exactly one lane apiece.

The subset is selected by which lanes carry the write-protect code, so ten new
cases -- one per unreached subset -- name three protected lanes to reach a
single-lane store and two to reach a pair. Protected lanes must come back
holding the fill pattern while written lanes hold unpacked data, so a sequence
that stores to a neighbouring lane fails on both halves at once. Two more cross
the selector with a mode, where the mode merge runs on a partial lane set
rather than the whole register.

Validated by mutation, each bounded to exactly the predicted set: swapping the
Z lane for W in the Y+Z sequence fails write_yz and write_yz_mode1 and nothing
else; moving the single-lane Z store from offset 8 to 4 fails write_z alone.

The remaining two switch arms stay unreached and are unreachable, which the new
absolute test pins from the other side. A fully write-protected cycle is
dropped by ProcessMasks before any store is emitted, so the "no lanes" arm is
guarded, not exercised; the differential case for it would pass whatever the
generator did, since it only has to agree with an oracle that also writes
nothing. FullyProtectedBlockWritesNothing asserts the fact itself -- VU memory
byte-identical to the fill pattern. The all-lanes arm is likewise dead: the
caller emits a plain full-width store when no lane is protected.

1705 tests, 1703 pass, 2 pre-existing skips.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 7a5ed084c4 tests: cover the T-bit end-program Q/P commit on VU1
A T-bit stop on a branch does not go through the normal end-of-program
routine; it has its own variant carrying a second copy of the Q/P commit.
That copy matters because committing a double-buffered scalar out of a
host vector means rotating lanes, and the rotate is not an involution —
undoing a 4-byte rotate takes a 12-byte one. A duplicated rotate that no
test ever runs is where that slip survives.

Reaching it needs both scalars still in flight at the branch, so the
end-of-program cycle advance is what retires them and flips the instance,
and VU1, since P exists nowhere else.

Mutation-checked: pinning either instance index to zero fails this case
and nothing else.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 770e73ec28 tests: cover the XGKICK wrap seam and the XgKickHack drain
VU1 memory is circular and the kick address is a rolling double-buffer
pointer, so a GIF packet straddling the top of memory is ordinary traffic.
The transfer has to split at that seam and resume at offset 0; split it
at the wrong offset and the GS receives the right byte count from the
wrong place. Neither the non-hack split nor the hack path's two-pass
equivalent had any coverage.

With the XgKickHack gamefix on (the GameDB forces it for several titles)
the drain changes shape entirely: a C helper meters the packet out
against accumulated VU cycles, carrying a residual size and a rolling
address across calls. That helper had never been executed by a test —
the existing XgKickHack case deliberately issues no kick, since it is
about register spilling around the sync site rather than the drain.

Also covers the end-of-program drain of a kick issued in the delay slot
of an E-bit branch. That kick is the last thing the block analyses, so
its latency never elapses inside the block and the emit loop's own drain
never runs for it. An ordinary E-bit doesn't reach the path — the
appended delay-slot pair decrements the latency first.

The non-hack wrap case can only assert its tail: the split's first half
goes out through CopyGSPacketData, which the test sink does not hook.
The tail is what pins the arithmetic anyway, since it must be exactly
(packet size - distance to the top) bytes taken from offset 0.

Mutation-checked: disabling the split fails only the wrap case, disabling
the end-of-program drain only the delay-slot case, and dropping the
helper's rolling-address advance only the two-chunk case.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 46307d2872 tests: cover the E/M/T-bit exit stubs on branches and jumps
When the E bit lands on a branch pair, the branch and the end-of-program
delay slots coincide: the branch runs, its delay slot runs, and the
program stops without executing the target. All the branch still decides
is VI[REG_TPC] — where the next dispatch of this VU picks up. Naming that
PC wrong doesn't crash anything, it silently restarts the microprogram in
the wrong place.

microVU handles each branch shape with its own hand-written exit stub and
its own incPC arithmetic, and none of normBranch's, condBranch's or
normJump's had any coverage. Each case here asserts the parked PC as an
absolute pair index and pins which successor actually ran, since a stub
that picks the wrong one still parks at a legal-looking PC. The backward
unconditional case is separate because a stub deriving the parked PC from
the fall-through still looks right on a forward branch.

The M-bit cases cover the same stubs used as a mid-program sync rather
than a terminator, and are scored per engine for the reason the T-bit
cases already are: the JIT compiles branch and delay slot as one unit and
parks at the resolved successor, while the interpreter's break fires on
the branch pair and leaves TPC on a delay slot it never ran.

Also covers the T-bit jump stub's INTC raise and the VU1 instantiation of
the runtime jump-compile entry point, which had never been called.

Mutation-checked: inverting condBranch's E-bit polarity fails exactly the
two conditional E-bit cases, inverting its M-bit polarity exactly the two
conditional M-bit cases, dropping normBranch's E-bit target exactly the
two unconditional cases, and dropping normJump's TPC store exactly the
jump case.
2026-07-28 15:24:36 -07:00
Brian Degenhardt f553eac8a6 tests: cover Q/P instance rotation across a branch
The VU's Q and P scalars are double-buffered, and microVU keeps both
buffers live in one host vector. When a DIV or an EFU op's latency
expires mid-block the current instance flips, but every compiled block
is entered assuming instance #0 — so a branch out of that block has to
physically swap the two lanes first. Nothing in the suite had ever
branched with a Q or P value in flight, so that swap was unreached.

A dropped swap is silent: the target block reads the previous quotient,
which is an ordinary float that propagates through the rest of the
microprogram. Each case therefore seeds the stale buffer with a distinct
sentinel and asserts the absolute post-branch value — a JIT-vs-interp
diff alone would also pass if the swap were dropped on both sides.

Also covers mVUendProgram's division-flag transfer, which only runs when
the program ends inside the FDIV flag latency. Every other Q test drains
the pipe with VWAITQ first, so that path had never run either. STATUS is
opted out of the cross-engine diff there: the console captures already
settled that the sticky D/I bits accumulate, which microVU does and the
shared interpreter does not (vu_sticky_console_conformance_tests.cpp).

Mutation-checked: neutralising the Q swap fails exactly the three Q
cases, the P swap exactly the P case, and dropping the end-program
mVUdivSet exactly the two flag cases.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 28a94f9ec0 tests: reach the by-element FMUL fold on plain MULbc without a config change
The previous commit said MULbc never reaches the fold under the shipped
clamp default. That is only true at the full xyzw mask: the Ft clamp that
suppresses the fold is gated on the full mask, so any partial multi-lane
mask -- the common shape in real microprograms -- takes the fold with the
default config.

Adds that case and corrects the comment. Mutation-checked: pinning the
fold's lane operand to 0 fails it.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 1c9d82a8ca tests: pin VU MAX/MINI sign-magnitude ordering
The PS2 VU has no infinity and no NaN. An exponent-0xFF word is an ordinary
very large number that MAX has to order like one, and a denormal is an
ordinary very small number that MINI has to order like one. Neither engine
uses a float compare: the interpreter branches on "are both operands
negative" and picks a signed integer min/max, while microVU flips the low
31 bits of every negative lane so a single signed compare works. Two
different derivations of the same order, which is what makes diffing them
worth doing.

Covers the packed helpers across exponent-0xFF words, both zeros,
denormals, both-negative pairs and equal operands; both broadcast and
I-register operand shapes; and the scalar single-destination-lane helpers,
which had no coverage at all and are the ones that would be quietly
replaced by an IEEE FMAX/FMIN by anyone simplifying the emitter.

Every case carries the expected bit pattern, so the suite states the
architectural answer rather than only asserting the two engines agree.

Also pins a divergence found while writing this: microVU folds the I-bit
immediate in as a constant and clamps an exponent-0xFF immediate down to
max-finite while doing so, keeping its sign, where the interpreter stores
the raw word. x86 mVU has the identical clamp, so this is upstream
behaviour we share -- but it means the interpreter is not the oracle for
MAXi/MINIi/ADDi/MULi with such an immediate, which is worth knowing before
it costs someone a divergence hunt. Scored per engine, with a companion
case showing agreement returns once the overflow clamp is off.

Validated by mutation: neutralising the negative-lane bit flip in the
packed helper fails exactly the two both-negative packed cases, and in the
scalar helper exactly the two both-negative single-lane cases. Every
mixed-sign and both-positive case stays green, since a plain signed
compare is correct there.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 1733ad6542 tests: sweep every broadcast lane of the VU upper-pipe FMACs
Two thirds of the VU upper pipe is broadcast forms, and the lane they read
is encoded in the opcode rather than an operand field, so the only thing
separating VMULy from VMULz in the emitter is a table index. A transposed
index produces a numerically plausible result that nothing asserts on --
it surfaces as subtly wrong geometry in one game.

Before this, MAXx/y/z/w, MINIx/y/z/w, MADDx/y, MSUBy/z/w, MULw and SUBy/z
had never been emitted by any test; microVU_Upper had executed 63 of its
119 functions.

Each of the 48 cases carries a hand-computed expected vector, so the suite
knows the right answer independently of both engines -- a diff-only test
would pass vacuously if a mis-encoded instruction decoded to something
inert in both. Ft holds four pairwise distinct values so every broadcast
lane yields a distinct result.

Also covers the by-element FMUL fold on all four lanes. MADDbc, MSUBbc and
MULAbc reach it under the shipped clamp default; plain MULbc at a packed
mask asks for an Ft clamp and so never does, and gets its own case with
the overflow clamp off.

Validated by mutation: pinning the fold's lane operand to 0 fails exactly
the 14 non-x cases whose op reaches the fold, and no others.

Adds the MAX/MINI and ADDA/SUBA broadcast encoders VuEncode.h was missing.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 05ff26bbe7 tests: cover COP2 macro broadcast MAX/MINI and the conversion family
recVMAXx/y/z/w, recVMINIx/y/z/w and most of recVITOF*/recVFTOI* had no
coverage: 58 of the 140 functions in iR5900Misc-arm64.cpp were never executed,
and the recCOP2_* implementations they forward to went with them.

Both groups are worth more than the arithmetic ops that already have tests.
MAX/MINI does not use a float compare at all -- the PS2 VU has no inf or NaN,
so cop2EmitIntegerMax orders operands as sign-magnitude integers via CMGT
corrected by a both-negative mask. That correction is invisible unless both
operands are negative, and the decision to compare as integers rather than with
Fmaxnm only shows up on exp-FF words, which is precisely what a QMTC2 leaves in
a register. Both are pinned here. ITOF/FTOI carry their scale in the opcode, so
a wrong shift is a silently wrong magnitude, and FTOI has to saturate where the
host instructions disagree about out-of-range conversions.

Oracle is the VU0 interpreter through EeRecTestHarness's JIT-vs-interp diff,
with absolute expectations alongside wherever the architectural answer is
unambiguous, so a failure says which side moved.

Verified by mutation: dropping the both-negative correction fails exactly the
two negative-operand cases and leaves the positive-only broadcasts green.

The I- and Q-register broadcast variants (VMAXi, VMINIi, VADDi, VADDq and
friends) are still uncovered -- EeRecTestHarness has no way to seed VU0's I or Q
registers, and building that out belongs in its own change rather than half-done
here.

pcsx2/arm64 line coverage 76.58% -> 76.95%, functions 82.57% -> 83.91%;
iCOP2-arm64.cpp 77.4% -> 82.0%, iR5900Misc-arm64.cpp 60.5% -> 65.2%.
recompiler_tests 1569 -> 1587.
2026-07-28 15:24:36 -07:00
Brian Degenhardt bd6697f277 tests: cover the arm64 VIF unpack generators
Both NEON unpack generators were entirely untested: Vif_UnpackNEON.cpp sat at
0% line coverage and Vif_Dynarec.cpp at 1.9%, together ~680 lines of lane
shuffling, sign extension and mask merging that every game drives on every
frame. A transcription slip in there produces silently wrong geometry rather
than a crash, which is the worst failure mode to have no gate for.

The oracle is VIFfuncTable (Vif_Unpack.cpp) -- the scalar UNPACK_S/V2/V4/V4_5
templates, plain C++, architecture-neutral, shared verbatim with upstream.
Deliberately not _nVifUnpack: on arm64 that dispatches through the NEON
routines for mode 0, so it would compare our codegen against our codegen.
ReferenceUnpack drives the scalar table with _nVifUnpackLoop's addressing, and
both generators are checked against it.

54 cases grouped by the failure each would catch rather than by enumerating the
cross product: per-format expansion (both signedness values for every sub-32-bit
format), the four mask codes including cycle-indexed columns and write-protect,
MODE 1/2/3 with row write-back, CYCLE skip and fill, the num/wl 256 boundaries,
and VIF0 as well as VIF1.

The W lane of V2_32 and the V3_* formats is excluded from the comparison: both
generators zero it in cases the scalar table does not ("tested on ps2", and the
x86 SSE generator agrees), while Vif_Unpack.cpp routes V3 through UNPACK_V4 on
purpose for Ape Escape 3. Re-deriving the generators' iteration arithmetic in
the test would only restate the code under test, so W is instead pinned by the
one independently checkable fact -- an aligned V2_32 unpack zeroes it.

Verified by mutation rather than by passing: forcing the column register to
cycle 0 fails exactly the three multi-cycle column cases and nothing else, and
zero-extending the 8-bit signed path fails exactly S8/V2_8/V3_8/V4_8 while the
unsigned variants stay green.

pcsx2/arm64 line coverage 74.65% -> 76.58%; Vif_UnpackNEON.cpp 0% -> 91.8%,
Vif_Dynarec.cpp 1.9% -> 82.7%. recompiler_tests 1515 -> 1569.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 47fa49f3d2 Build: add source-based coverage for the ARM64 recompilers
USE_COVERAGE instruments the build with clang's -fprofile-instr-generate
-fcoverage-mapping, exposed as the clang-coverage preset (build-coverage/,
inheriting clang-devel so the dev asserts stay on, Qt off since nothing in
the test path needs it).

The instrumentation is tree-wide rather than scoped to pcsx2/arm64: much of
the JIT is inline code living in headers that get pulled into core and common
translation units, so narrowing at build time would drop counters for exactly
the code we care about. tools/coverage.sh narrows at report time instead,
where the filter is exact.

The script builds the five gtest binaries, runs them with per-process profile
files, merges, and reports scoped to pcsx2/arm64/ (--scope all widens to the
other ARM64-only sources). Two hazards it defends against:

  - cmake --preset takes the source dir from the working directory and ignores
    -S, so running this through the /home/bmd/ARMSX2 symlink bakes the
    symlinked path into every coverage mapping. It cds first.
  - llvm-cov does not error when --sources matches nothing; it reports every
    file it has data for, which reads as a plausible whole-tree number. The
    filter prefix is read back from CMAKE_HOME_DIRECTORY so it always matches
    what the compiler recorded, and a row-count tripwire fails the run if the
    report escapes its scope anyway.

Baseline for pcsx2/arm64/: 74.65% lines, 79.94% functions, 77.70% regions.
2026-07-28 15:24:36 -07:00
Brian Degenhardt 737966bbad CI: publish the libretro core and SDL handheld build from the nightly
Both jobs have been in build-all.yml since it was written, so they build
and get artifact-uploaded on every push, but neither was ever added to
nightly.yml. The result is that the RetroArch core and the bare-kmsdrm
handheld frontend are the two targets with no published download at all,
which is backwards: those users are the least likely to build from source.

Wire both into the nightly with the same inputs build-all.yml passes, and
collect their .tar.zst into the release. Non-blocking, like the mobile
jobs: publish's guard names only the PC jobs, so a failure here costs the
asset rather than the release. Unlike the mobile jobs they cannot be
marked continue-on-error, since that key is not permitted on a job that
uses a reusable workflow, so a failure will still redden the run. That
matches how they already behave in build-all.yml.

Both build at OVERRIDE_HOST_PAGE_SIZE=4096, so the release notes say so.
Neither is packaged as an AppImage on purpose: the AppImage runtime wants
FUSE, and a bare-display handheld is exactly where that cannot be assumed.
package-sdl.sh already bundles the libs it built and points the rpath at
$ORIGIN/lib, so the tarball is self-contained without it.
2026-07-28 15:24:35 -07:00
Brian Degenhardt 9ec5c46ab6 CI: give nightly release assets one dated, self-describing name
The nightly attached whatever filename each build job happened to produce,
and the job families use three unrelated conventions: the PC jobs share
name-artifacts.sh (armsx2-<target>-sha[<sha>]), Android bakes in a
versionCode derived from Unix seconds, and iOS ships a fixed
ARMSX2-iOS-unsigned.ipa. So a downloaded file carried no date at all (that
lives only in the release title), iOS carried no build identity whatsoever
(two nightlies collide as "(1)"), and GitHub rewrites the '[' and ']' of
sha[...] to '.' on asset upload, leaving names that read as though they
have a second file extension.

Rename in the publish job as assets are collected, to

    ARMSX2-nightly-<YYYYMMDD>-<sha>-<platform>.<ext>

which keeps the per-workflow CI artifact names untouched for the Actions
tab and for build-all.yml, so the blast radius is the release page only.
A missing artifact (a failed non-blocking job) logs MISSING and the step
still exits clean, so it costs that asset rather than the release.

Also replace the one-line platform list in the release notes with a short
per-file legend, since which Linux AppImage to take is not something a
downloader can infer, and picking the wrong page size just fails to run.
2026-07-28 15:24:35 -07:00
Brian Degenhardt 55c22d3ca2 GS: fix missing draws on Adreno when texture replacements are loaded
Tales of the Abyss with an HD texture pack loses its entire 2D text layer on
Vulkan/Adreno (#442). The replacement shifts the source alpha range, which flips
those draws to require_one_barrier; the draw then reads the render target back
while it is still bound as the colour attachment, and the driver silently drops
it.

Device A/B on Turnip/Mesa 26.1.2 + Adreno 650: both in-pass forms fail -- the
subpassLoad input attachment and the feedback-loop-layout texelFetch sampler --
while reading a separate copy of the target renders correctly. Not tile-size
related; the text is missing at 1x as well as 4x.

Route this through the driver-bug database instead of another inline vendor
test. That database was built for exactly this and had never been consulted:
its sources were compiled only under if(ANDROID) and were missing from
pcsx2.vcxproj, and every call site was #if defined(__ANDROID__) -- so every
rule was dead on the ARM Linux handhelds we test on, including the device that
reproduces this bug. Resolve the driver profile on all platforms and give it
its first HasBug/UsesWorkaround consumer. The mobile-only consequences (runtime
GPU profile, GS pool tuning) stay Android-gated on purpose: off Android the
detector classifies every non-Mali GPU as Adreno, and desktop pool sizing is
not this code's business. Non-Adreno targets resolve to zero rules and zero
workarounds, verified on Apple/Honeykrisp.

Narrow the workaround to when replacements are loaded. NFS Underground pushes
608 barrier draws per frame through the same in-tile self-read with no pack and
renders correctly, so the read is fine for ordinary blending. Applying the copy
unconditionally cost +38%/+40% frame time at 3x/4x on an NFSU dump replay
(copies 5 -> 348 per frame, render passes 62 -> 391) for no correctness gain.

This replaces an is_adreno block that forced the subpassLoad path on regardless
of INI. Its own comment already recorded that the feedback-loop sampler drops
content; what it missed is that subpassLoad drops it too, so it was choosing
between two broken reads. Adreno joins vendor_allows_fbfetch so removing the
force does not demote the proprietary blob to the per-primitive barrier path,
and DisableFramebufferFetch now actually takes effect there instead of being
eaten.

LoadTextureReplacements joins RestartOptionsAreEqual: it now selects the
tfx.glsl RT-read variant at shader-compile time, so toggling it in place would
leave the feature flag and every compiled pipeline disagreeing with the setting.

OverrideTextureBarriers still wins when set explicitly -- 1 restores the in-tile
path, 0 forces the copy for anyone who hits this without a pack.
2026-07-26 22:17:10 -07:00
Brian Degenhardt b2d57d93f6 GS: correct the textureCompressionBC comment on the replacement decode path
The comment asserted as fact that Vulkan textureCompressionBC is false on
"Adreno 650 / Snapdragon 865, and Mesa Turnip on any Adreno". Neither holds:
Turnip reports it true, and on Adreno 650 the Qualcomm blob gained BC at driver
512.614 (vulkan.gpuinfo.org splits cleanly across that revision). It is a driver
property, not a hardware one.

No behaviour change -- the CPU decode is already gated on the runtime feature
bits. The comment sent an investigation down the wrong path, which is the cost
being fixed here.
2026-07-26 22:16:54 -07:00
Brian Degenhardt 43e3430b61 SPU2: remove the SVE2 reverb FIR and its MT6899 tuning header
spu2_sve2_fir.h offered SVE2 versions of the reverb FIR behind
SPU2_HAS_SVE2_COMPILER, which is off on every target we build. It has never
been compiled by anyone, and it would not compile if tried: the upsample
coefficient table declares 32768 in an int16_t initializer, which is a
narrowing error, not a warning. Clang rejects it outright.

The arithmetic is wrong too. ReverbDownsample_reference accumulates the
products and then does out >>= 15; the SSE, AVX and NEON paths get that scale
implicitly from mulhrs / vqrdmulhq_s16. The SVE2 version accumulates with
svmlalb/svmlalt and hands the raw sum to clamp_mix with no shift at all, so
every sample would saturate. The same coefficient the initializer rejects is
one the reference clamps to 32767 in make_up_coefs, so the table was wrong on
its own terms.

That leaves spu2_mt6899_tuning.h unreferenced: its only consumer anywhere was
GetFeatures() from inside the SVE2 block. It held Cortex-X925 cache geometry,
prefetch distances and thread-pinning helpers for a device that is not one of
our targets.

RegisterNEONBackend now installs the NEON FIR unconditionally, which is what
it already did in every build that exists.
2026-07-26 19:45:16 -07:00
Brian Degenhardt 2ce8c27e13 EE/arm64: remove the FORCE_INTERP_* per-category bisect switches
Eleven commented-out defines in iR5900-arm64.h, each selecting the interpreter
for one opcode category (branch, jump, move, shift, ALU, arith-imm, mult/div,
memory, COP0, FPU, COP2), consumed by twelve #ifdef/#else/#endif pairs across
eleven files. Using one meant editing the header and rebuilding.

pcsx2-eerunner --rec-fallback <groups> does the same bisect at runtime with no
rebuild and no source edit, over a full VM boot, so it stays game-faithful.

The transform keeps every #else body byte for byte - the diff is deletions
only, no added or reindented lines. Also drops two comments that referenced
the defines.

recompiler_tests: 1513 passed, 2 skipped, 0 failed.
2026-07-26 19:40:52 -07:00
Brian Degenhardt 516650a066 IOP/arm64: remove the unused psxRecompileCodeConst templates
psxRecompileCodeConst0/1/2/3 and the five PSXRECOMPILE_CONSTCODE macros came
across in the JIT transplant as x86 const-propagation dispatch templates.
Nothing invokes them: the arm64 IOP recompiler emits through its own
allocator-aware macros in iR3000Atables-arm64.cpp, which handle the const
cases inline. The one PSXRECOMPILE_CONSTCODE0 mention left in that file is a
comment noting the x86 form the arm64 macro corresponds to.

The R3000AFNPTR / R3000AFNPTR_INFO typedefs went with them - they had no other
users.

psxRecompileIrxImport sat between these declarations and is very much live
(iR3000Atables-arm64.cpp:114/123/125), so it is kept, unchanged, next to the
branch-handling section.
2026-07-26 19:39:26 -07:00
Brian Degenhardt 9eb25cc3f0 EE/arm64: remove VERIFY_NATIVE_CODEGEN
An in-JIT differential mode: snapshot the guest register file, run the native
codegen, flush, then call back into the interpreter to compare. In practice it
only ever covered COP2 (opcode 0x12), and VERIFY_NATIVE_CODEGEN was never
defined anywhere, so all 224 lines compiled out of every build.

The offline tools do this better. pcsx2-eerunner localizes JIT-vs-interp
divergence over a full VM boot, --divtrace names the first divergent op, and
--rec-fallback bisects by opcode group without a rebuild. None of them
perturb codegen the way an inline verify hook does.

Removing the #ifdef branch leaves the remaining else-body as a bare scope, so
it is unwrapped and re-indented here.

recompiler_tests: 1513 passed, 2 skipped, 0 failed.
2026-07-26 19:37:32 -07:00
Brian Degenhardt d1c483b2b3 SPU2: remove the unwired NEON mixer/reverb/DC-filter headers
These four headers came in with a contributor drop aimed at a MediaTek MT6899
(Cortex-X925). The useful part of that drop was kept: spu2_neon.cpp registers
the 39-tap NEON reverb FIR on every arm64 target. The helper headers were
never included by any translation unit, and spu2_neon.cpp carried a note
explaining why - they target mixer.cpp and a "ReaVerb.cpp" that does not exist
here, and they use the MSVC-only __forceinline unguarded, so they would not
compile as written.

They are also wrong where it counts. spu2_neon_mixer.h's GaussianInterpolate,
the one on the hot path at 24 voices x 48 kHz, disagrees with Mixer.cpp three
ways:

  - Truncation order. GetVoiceValues shifts each tap ((coef * sample) >> 15,
    four times, then sums). The helper sums the four products and shifts once.
    Arithmetic shift is floor division and does not distribute over addition;
    over 200k random tap sets the two forms differ 95.8% of the time. The
    hardware truncates per tap, so this is less accurate, not more. Its own
    scalar fallback has the same bug, so it does not match "original
    behavior" either.
  - Element type. It takes const int16_t* and does vld1_s16, but DecodeFifo is
    s32[32].
  - Addressing. It assumes four contiguous samples; the real index is
    (DecPosRead + n) % 32, which wraps.

spu2_neon_dcfilter.h is merely pointless rather than wrong: a two-lane f32
operation on a serial IIR chain with no ILP to exploit, whose batch entry
point just loops the per-sample one, and whose combined convert/clamp/filter
path round-trips through memory.

spu2_optimize.h only reached the build through spu2_neon_reverb_ex.h.

spu2_sve2_fir.h and spu2_mt6899_tuning.h stay: spu2_neon.cpp includes both.
2026-07-26 19:35:54 -07:00
Brian Degenhardt 6e5770be8d iOS: remove the unreferenced TestHarness, QAProbe and SifRingBuffer
All three compiled into iOS builds with no caller anywhere - nothing in
platforms/ios names them and no translation unit includes their headers. They
came in with the iPSX2 runtime import and were never wired to the shared core.

TestHarness (2513 lines) injected R5900 machine code straight into eeMem at
0x81F00000, pointed cpuRegs.pc at it, and had the vsync handler scrape
pass/fail out of guest memory - a BIOS/SIF/IOP-independent way to check EE JIT
instruction accuracy. tests/ctest/core/recompilers covers that ground now, and
does it without a booted VM. It was also gated on an iPSX2_TEST_HARNESS
environment variable, which shipped code is not supposed to carry.

QAProbe drove scripted QA capture; SifRingBuffer held a standalone SIF ring
mirror for hang diagnosis.

The TestHarness mentions left in Gif_Unit.h, vu_capture.h and iR5900-arm64.cpp
are comments about VuTestHarness and EeRecTestHarness under tests/ctest, which
are unrelated to these files.
2026-07-26 19:35:18 -07:00
Brian Degenhardt fc55cd86f1 Android: remove the orphaned perf-bucket and PS1DRV trace headers
AndroidPerfBuckets.h wrapped hot EE/VU/GS/VIF paths in steady_clock reads and
relaxed atomic adds, gated on ARMSX2_ANDROID_PERF_BUCKETS. It served its
purpose: the 2026-06-17 run used it to pin the dominant EE cost on
ee_interp_step. Its call sites are all gone now, so the header's claim that
"the atomic counters and every call site stay compiled in either way" no
longer holds, and nothing anywhere names AndroidPerfBuckets::. Its one
remaining consumer was arm64/Vif_Dynarec.cpp, which included it twice on
consecutive lines and used nothing from it - that duplicate include goes too.
Perf jitdump covers this now.

PS1DrvTrace.h supplied rate-limited PS1DRV_LOG_/RATE_/CHG_ macros for PS1-mode
debugging, each gated on a PS1DRV_TRACE_<CAT> define. No translation unit ever
included it.
2026-07-26 19:34:36 -07:00
Brian Degenhardt 15eb94fb74 Remove EEDiffVerify: a live toggle in front of a dead diagnostic
EEDiffVerify was a throwaway EE recompiler-vs-interpreter differential
verifier, written to pin the True Crime NYC (SLUS-21106) texture-decompressor
corruption. It worked by emitting snapshot/verify hooks around each
straight-line op and re-running that op on the interpreter with stores
captured rather than applied.

Those hooks lived in the pre-transplant arm64/mac EE recompiler and in
vtlb.cpp, and 2eb9ec659c ("refactor: move Android frontend to platforms/android
on single shared core") dropped both. What survived is the shell:
g_ee_diff_verify is read by nothing, and eeDiffSnapshotPre, eeDiffVerify and
eeDiffCaptureStore are called by nothing.

The Android toggle in front of it stayed fully wired, so flipping it set a
flag no one reads and reset the EE recompiler - a visible hitch in exchange
for nothing at all.

pcsx2-eerunner covers this ground better anyway: two-pass offline JIT-vs-interp
localization over a full VM boot, so it is game-faithful by construction,
with --divtrace for first-divergent-op and --rec-fallback for group bisects.

Removes the module, its Android JNI pair, the NativeApp declarations, the
Recompiler-tab toggle and its now-empty Diagnostics header, the search-index
entry, and the three strings across all 19 translations.
2026-07-26 19:34:05 -07:00
Brian Degenhardt 7aadd3ae64 Android: drop the duplicate vendored googletest
platforms/android/.../cpp/3rdparty/googletest was a byte-for-byte copy of the
tree's own 3rdparty/googletest (66 files, 1.8 MB each; diff -r reports no
differences). The root copy is the live one - the top-level CMakeLists adds it
and tests/ctest links gtest from it.

The Android copy existed only for the on-device test tree removed in the
previous commit, and after that its sole remaining mention was a comment.
2026-07-26 19:32:09 -07:00
Brian Degenhardt c2bd7ce2a0 Android: remove the on-device JIT test tree superseded by the in-tree gates
platforms/android/.../cpp/tests/ held ~11,900 lines of EE, microVU, VIF,
patch and ARM64 codegen tests, none of which any build compiled. Only the
35-line android_test_stubs.cpp was in the Android CMake, supplying no-op
definitions so native-lib's JNI entry points stayed linkable.

The tests drive the pre-transplant arm64/mac backend through EE_Test*,
mVU0_Test* and mVU1_Test* hooks. Those fourteen symbols exist nowhere in this
core, so the suite cannot compile against it. The stub file asked for exactly
one thing - "restore the real tests once a compatible test backend is
reconciled into this core" - and that reconciliation is what
tests/ctest/core/recompilers now provides, alongside pcsx2-vurunner,
pcsx2-eerunner and the DiffJitVsInterp harness.

Also drops the surface that fed it: six JNI entry points, the
ReportTestResults JNI callback, six NativeApp declarations, TestResult.kt and
six mutableStateOf holders in MainActivityRuntime that nothing ever read.
MainActivityRuntime called runEeJitTests/runEeSeqTests/runVifTests
unconditionally during Android init, so every launch made three JNI round
trips that only logged "recompiler self-tests are disabled in this build".
2026-07-26 19:32:01 -07:00
Brian Degenhardt 1da102f45b Remove VU1Fingerprint: orphaned since the Android frontend refactor
VU1Fingerprint hashed VU1 microprograms at upload/dispatch so that known
shared libraries (sceVu*, RenderWare RpVU1*, ...) could later be swapped for
hand-written NEON kernels. Only the Phase 1.5 infrastructure was ever built;
the kernel database was intentionally left empty.

Its three call sites - Vif_Codes.cpp, MTVU.cpp and the pre-transplant
arm64/aVU.cpp - were dropped by 2eb9ec659c ("refactor: move Android frontend
to platforms/android on single shared core"). The module has been unreachable
since, and the ARM64 VU it hooked no longer exists.

The identity layer it needs now lives in microVU_ProgCache-arm64, which
already hashes program content (XXH128, options sentinel folded in), owns the
content map and observes dispatch. If program recognition is revisited it
belongs there rather than in a parallel implementation.

platforms/android/tools/vu_disasm.py still decodes raw VU bytecode; only its
former dump producer is gone.
2026-07-26 19:17:54 -07:00
Brian Degenhardt a5bf32fa92 GS: spin briefly before blocking on semaXGkick
MTGS blocked outright waiting for MTVU to finish a VU1 program, which
drove the semaphore counter negative, so every MTVU-side Post() took the
futex-wake syscall path. Use the existing UserspaceSemaphore spin-then-
block wait so the common case -- MTVU posting within microseconds --
resolves in userspace.

Only reachable in MTVU mode: semaXGkick is touched solely by this wait
and MTVU's post, and WaitWithSpin() had no other caller.

NFS Hot Pursuit 2 on an SM6115 handheld: 23.9 -> 34.2 fps, with MTVU
syscall time falling from 11.8% to 0.06% of the thread.
2026-07-26 15:30:32 -07:00
Brian Degenhardt 1b66b8d0b1 Memory: refuse Extended RAM while the ARM64 EE recompiler is enabled
ExtraMemory (the 128MB devkit map) is selectable from both shipping UIs with
nothing but a cosmetic compatibility warning, but the ARM64 EE recompiler is
MainRam-only: its LUT loop, recLutEntries, the recRAM advance, the alias mask and
the manual_page/manual_counter arrays are all sized to Ps2MemSize::MainRam, where
the x86 rec sizes the same things to ExposedRam. Pages 0x0200-0x1FFF keep the
unmapped default, so dispatching into one lands on UnmappedRecLUTPage -> recError
somewhere deep inside a game, with nothing tying the crash back to the setting.

Converting the LUT, the mask and the manual-page arrays together is the real fix
and has to land as one change; c4d0a8a47c already spells that out. Until then,
fail at the seam instead: memSetExtraMemMode is the single choke point both
VMManager call sites route through, so ignore the mode there and say so on the
console. Gated on the recompiler, not the arch alone -- the interpreter handles
the 128MB map fine.
2026-07-26 15:01:08 -07:00
Brian Degenhardt 2c02dc8b96 GS: drain the back queue in GSreopen before shredding its textures
GSreopen opens with GSParseTarget()->Flush(GSREOPEN), which flushes FRONT parse
state and *queues* the resulting draw -- GSState::Flush does not drain. Both arms
below it then hand the back thread's textures to the shredder: the device-loss
arm (recreate_device && !recreate_renderer) calls PurgeTextureCache, ClearCurrent
and PurgePool, and the other arm reads the texture cache back. Same class as the
three window/vsync seams fixed in 578cb3a83a, which is where this was found and
deliberately left alone pending the safety question.

That question resolves in favour of draining. The worry was that on device loss
the back thread could be wedged in the driver and waiting on it would hang
recovery instead of recovering. It cannot: BeginPresent only reports DeviceLost
off m_last_submit_failed, so the driver has already declared the loss by the time
we get here, and post-loss calls return VK_ERROR_DEVICE_LOST rather than
blocking. There is no backlog to chew through either -- SubmitVsync drains before
ExecVsyncRecord and present never queues, so the queue is empty on entry and the
Flush above is the only producer.

Note this is NOT the Android suspend/resume path. Backgrounding kills the
surface, not the device: BeginPresent returns FrameSkipped and resume comes back
through onNativeSurfaceChanged -> MTGS::UpdateDisplayWindow, which 578cb3a83a
already drains. The trigger here is genuine device loss, which the tree documents
twice -- the Mali r44p1 blob that returns VK_ERROR_DEVICE_LOST on every game, and
Rogue Galaxy hitting it at vkWaitForFences.

DrainBackQueueBeforeDeviceMutation moves above GSreopen unchanged so it can be
called from there. No test: this seam class has no runtime test surface, same as
578cb3a83a.
2026-07-26 15:01:08 -07:00
Brian Degenhardt 11865fe54b VMManager: delete the four dead arch #else bodies
Upstream guards these blocks with `#ifdef _M_X86 // TODO(Stenzek): Remove me
once EE/VU/IOP recs are added.` The arm64 JIT merge widened each guard to
`#if defined(_M_X86) || defined(ARCH_ARM64)` and left the `#else` bodies in
place, but Pcsx2Defs.h defines ARCH_X86 and ARCH_ARM64 exhaustively (anything
else is an #error) and _M_X86 is set on every x86 build -- by
BuildParameters.cmake for CMake and by common.props for MSVC. So none of the
four `#else` arms can compile on any supported target, and the recs upstream's
TODO was waiting on now exist. Collapsed all four.

Two of them were near-duplicates of the live branch carrying stale
Phase-4.3/6/7.8 commentary. The third, in ClearCPUExecutionCaches, is the one
worth naming: its dead body reset recCpu and psxRec unconditionally, with a
comment claiming that had to happen even when a rec is not the active
provider. It does not, and dropping it is not a behaviour change on top of it
already being unreachable -- ClearCPUExecutionCaches opens with
Cpu->Reset()/psxCpu->Reset(), and every path that can make a recompiler active
calls UpdateCPUImplementations() immediately followed by
ClearCPUExecutionCaches() (VM init, and Execute()'s interpreter/rec toggle),
so a rec is reset at the moment it becomes the active provider. x86 upstream
never resets a non-selected rec either.

No functional change on either arch. recompiler_tests 1443/1443.
2026-07-26 15:01:08 -07:00
Brian Degenhardt c2a4690474 VMManager: un-nest the ARM64 arm of the CPU extensions log
The `#ifdef ARCH_ARM64` sat inside the `#ifdef ARCH_X86` opened four lines
above it, so it could never compile and the whole "CPU Extensions Detected"
section was missing from every ARM log -- which is where we most want it.

Made it an `#elif`, and reported something worth reading while there. NEON
alone is architectural on AArch64 and therefore constant; what varies across
our targets is LSE (absent on the ARMv8.0 handhelds) and SVE, since SPU2
selects its SVE2 path at compile time and a mismatch there is the first thing
to check on a SIGILL report. cpuinfo_initialize() already runs unconditionally
in CPUThreadInitialize immediately before this call, so the predicates are
valid on ARM; only the early-hardware-check call site is x86-gated.

Verified on an M2 Max under Asahi: "NEON LSE CRC32".
2026-07-26 15:01:08 -07:00
Brian Degenhardt 71248899ba IOP: probe the SMC coverage array by HWADDR in the store stubs
The out-of-line RAM-store fast path computed its g_iopCodeCov index from the
mirror-collapsed RAM offset (addr & (ExposedIopRam-1)), while iopCovAdjust and
psxRecClearMem key that same array by HWADDR -- which strips the KSEG base but
does not collapse the RAM mirrors, because recLUT_SetPage writes
psxhwLUT[page] = -(pagebase << 16) and pagebase is 0 across the whole 0x00-0x7f
RAM window.

In the default 2MB configuration the two disagree. A block compiled at
0x00214000 registers coverage at granule 0x2140; a store to that same address
probed granule 0x140, read zero, and returned without clearing. The C path
would have cleared it -- psxRecClearMem's own O(1) reject and its recBlocks
lookup both use HWADDR, so store and block agree there. So this was a real
regression introduced with the stubs, not the pre-existing blindness the
in-file comment claimed.

Above the region gate every reachable address satisfies
HWADDR == addr & (kIopCovSpan-1): bits 23-28 are zero, and the psxhwLUT
subtraction for a KSEG mirror is exactly the removal of bits 29-31. So the fix
is one extra AND, and none at all in the 8MB configuration where the RAM mask
already spans the coverage window.

The stub is now exactly as blind as the C path it replaces, no more: a store
to a *different* mirror of a block's page still misses, because recBlocks is
itself keyed by HWADDR. Rewrote the comment that asserted this was all
harmless, since it would have stopped the next reader from looking.

New test compiles a block at the 2MB RAM mirror and JIT-stores to it; red
before this change (JIT 0x0BAD vs interpreter 0x1337). The two existing mirror
tests use KSEG mirrors, where every domain agrees and the bug cannot show.

recompiler_tests 1443/1443.
2026-07-26 15:01:08 -07:00
Brian Degenhardt 2e40d9e799 GS: drain the back queue before the three window/vsync device seams
GSResizeDisplayWindow, GSUpdateDisplayWindow and GSSetVSyncMode all reach
g_gs_device from the MTGS thread -- swapchain resize, window recreate, vsync
change -- while the back thread is executing draws against that same device.
Every sibling seam of this class drains first; GSUpdateConfig does, and its
comment names this exact hazard. 5aeb3dd8bc added drains to the class and
missed these three.

The drain is sound for the same reason it is in GSUpdateConfig: the front only
parses on the MTGS thread, so one drain up front quiesces the back thread for
the whole call. It costs nothing when the back thread is off (the default) --
DrainBackQueue early-outs on consumer_running -- and the null check is real,
since g_gs_device is created before g_gs_renderer.

Re-sweeping the rest of GS.cpp for the same class: CloseGSDevice is safe by
ordering (always after CloseGSRenderer, and ~GSFrontState drains), and the
device reads (GetRenderAPI, stats, GetWindowInfo) are not hazards. GSreopen's
device-loss branch does purge the texture cache and the device pool with draws
possibly still in flight, but the drain is NOT obviously safe there -- if the
device is lost the back thread may be wedged in the driver, and waiting on it
would hang recovery. Filed separately rather than fixed blind.
2026-07-26 15:01:08 -07:00
Brian Degenhardt 848e22c3ae PS1DrvTrace: drop the include of a header that does not exist
PS1DrvTrace.h includes "arm64/InterpFlags.h", which is nowhere in the repo --
it was a JIT-bisect scaffold of commented-out INTERP_* toggles that went away
when the JIT matured. Latent today because no .cpp includes PS1DrvTrace.h; the
first consumer would have broken the build.

The include was wrong even when the file existed: InterpFlags.h never defined
PS1DRV_TRACE_<CAT>. Those are developer-set toggles, so say that instead.

Syntax-checking the header standalone then turned up a second defect in the
same class -- the documented macros PS1DRV_TRACE_LOG/RATE/CHANGE(CAT, ...) do
not exist. CAT is part of the name: PS1DRV_LOG_<CAT>, PS1DRV_RATE_<CAT>,
PS1DRV_CHG_<CAT>. Corrected, and verified by compiling a TU that enables all
six categories and calls the macros.
2026-07-26 15:01:08 -07:00
Brian Degenhardt 1e7661fe7a EE: clear the fastmem backpatch map on an arm64 recompiler reset
recResetRaw rewinds the code cache, which dangles every fastmem backpatch
record in vtlb's map, but arm64 never called vtlb_ClearLoadStoreInfo(). x86
iR5900.cpp does, right after recBlocks.Reset().

Not a mispatch risk -- vtlb_AddLoadStoreInfo erases a colliding code_address
before inserting, so a recycled address overwrites the stale record. It is a
leak: nothing else prunes the map, so it accumulates for the entire VM
session across every reset.

Only the EE recVTLB registers backpatch info on either arch, so clearing it
here is complete and cannot strand the IOP.
2026-07-26 15:01:08 -07:00
Brian Degenhardt e2474e14b9 GameDB: stop discarding a HWDownloadMode of Asynchronous
The GSHWFixId::HWDownloadMode apply path range-checks the raw wire value with
`value > Enabled && value <= Disabled`. Asynchronous (5) was appended after
Disabled (4) for ini/GameDB wire compatibility, so an entry asking for it was
accepted by the parser and then silently dropped here -- no diagnostic, no
fallback, the game just kept the default.

Bound the check at the last enumerator instead, and say in the comment that
this is a range check over the wire value rather than the accuracy ordering
Config.h forbids comparing on, so the next append updates it.

Twin of the VMManager.cpp warning fixed in 8a161bddc5, which was the same enum
tripping the same way.
2026-07-26 15:01:08 -07:00
Brian Degenhardt fe32ef2c27 EE: flush the source pins before QFSRV's raw adjacent-source load
recQFSRV has a fast path for Rs == Rt+1 that reads the contiguous 256-bit
{Rt:Rs} window straight out of cpuRegs.GPR with an unaligned raw Ldr. Its
comment claimed the window was "memory-coherent after the flushes above",
but those flushes are mmiFlushReg -> _deleteEEreg, which reconciles
const-prop and the scalar/NEON slots and never touches the pins.

Under lazy-dirty the pin is authoritative for UD[0] and armStoreEERegPtrRaw
elides the canonical store entirely for a pinned lane-0 write, so a pinned
source's lower half in memory is routinely stale mid-block. Nine GPRs are
pinned, which makes four adjacent pairs both-pinned -- ($at,$v0) ($v0,$v1)
($v1,$a0) ($a0,$a1) -- plus eight more with one pinned operand: exactly the
register range a funnel-shift memcpy loop uses. Failure mode is wrong data,
not a fault.

Every other raw quad-load site fixes this by merging the pin into lane 0
after the load, which cannot work here because the read straddles two guest
registers. Flush the two pins the window covers instead -- it covers exactly
r[Rt] and r[Rt+1], since sa <= 15 over their 32 bytes -- via a new
armFlushEEGPRPin. That keeps the fast path (0-2 extra Str) rather than
falling back to the ~10-instruction temp-buffer path, and the flushed pins
stay authoritative.

This was the last raw address-of-GPR read in pcsx2/arm64/; the GE-M2e sweep
in 3bc64ac11a covered the mergeable sites and missed this one. Also fixes
the comment, which is what made the hole look deliberate.

Tests: two red-on-unfixed cases dirtying a pinned Rt and a pinned Rs, plus a
non-adjacent green control that proves the divergence belongs to the fast
path. recompiler_tests 1442/1442.
2026-07-26 15:01:08 -07:00
Brian Degenhardt 77a4a2366a Merge pull request #435 from pstef/tests
Add more tests

Console-conformance suites for EE MMI / FPU control registers / loads and
stores / SA and the performance counters / the data and instruction
caches, IOP loads, stores and branches, VU0 COP2 macro mode, VU1 EFU, and
VU sticky flags. Each case is scored against a PS2 hardware capture on the
interpreter and the JIT separately rather than against the other engine,
so a defect the two share is still visible. 1439 -> 1509 cases.

Two fixes ride along, each confirmed load-bearing by reverting it:

  * psxJALR read its branch target out of Rs after writing the link, so
    `jalr $t0, $t0` jumped to the link address instead of the old Rs.
    Reverting fails BranchDelaySlotOrderingMatchesConsole alone.
  * MTSA masks to four bits. The console says `mtsa 0x10` leaves SA at 0
    and `mtsa 0xFFFFFFFF` leaves 0xF, and the x86 recompiler already
    masked on both of its paths, so this aligns the interpreter with what
    the JIT had been doing. Reverting fails four cases.

Twenty-seven DISABLED cases record console divergences PCSX2 has not
closed yet, each a tripwire that starts passing when the gap does. None of
them disables a case that used to pass. One is ours: cop2EmitFlagUpdate
builds the MAC flag from sign and zero only and clears U/O outright, so
arm64 COP2 macro mode raises no underflow or overflow flag.
2026-07-26 14:23:28 -07:00
Brian Degenhardt 539f4f7247 Tests: guard the EE cache2 host mapping behind MAP_FIXED_NOREPLACE
MAP_FIXED_NOREPLACE is Linux 4.17+; Darwin's <sys/mman.h> has no such
macro and Windows has no such header, so the unguarded include plus bare
use broke the macOS CI job outright — macos_build.yml builds `unittests`
and hard-fails when the recompiler_tests binary is missing, making this a
compile error there rather than a skipped test.

__has_include for the header, #if defined for the flag, and MapAt returns
nullptr when neither is available. Both callers already GTEST_SKIP on a
null return, so the two tests that need a page at a chosen host address
skip off Linux and nothing else moves. Verified by compiling this TU with
the macro #undef'd: clean build, those two skip, the other eight pass.

Also records why they skip on a 16K-page kernel: all four candidate
addresses are 4K-aligned but none is 16K-aligned, so Asahi, Apple Silicon
and some Android reject every one. That is not the loader collision the
comment assumed.
2026-07-26 14:22:41 -07:00
Brian Degenhardt 3410a08c2b GS: skip the PS2 Z floor on Apple GPUs
Depth written from the pixel shader does not bit-match the fixed-function
interpolation that a later read-only pass tests against on Apple GPUs, so a
GEQUAL retest of the same geometry drops out along shared triangle edges and
whatever was drawn underneath shows through as pinpoints of light. Black
(SLUS-21376) speckles white over dark walls; God of War II's Athena statue
speckles blue.

The PS2 32-bit Z floor is the only reason a depth-writing draw takes the
gl_FragDepth path at all. Its arithmetic is exact -- z*2^32, floor, *2^-32 is
an integer op between two exponent shifts -- and it only ever lowers the
stored value, so it masks the mismatch rather than causing it. Stray pixels
on a Black wall against the software renderer, Vulkan on an M2 Max:

  floor + gl_FragDepth (shipping)      748
  gl_FragDepth, floor removed         7062
  floor - 1 Z unit                       0
  floor + 1 Z unit                  263082
  no gl_FragDepth at all                 0

The disagreement is therefore under one PS2 Z unit, and a coplanar retest has
no margin to absorb it. OpenGL reproduces at exactly 748 as well, which rules
out the API and leaves the hardware. Whole-frame divergence from the software
oracle drops 762 -> 2 on Black; the God of War II and NFS Underground dumps
are unchanged.

This is what no_ps2_z_quantization already does for Mali, and the floor only
landed in January, so opting out returns Apple to long-standing behaviour.
Wire the flag up for Metal and OpenGL too, neither of which read it before --
Metal is how Mac and iOS actually reach this, and it is the only backend the
bug was reported on. Both now honour the INI override as well.

Vulkan gates on driverID rather than vendorID because Apple silicon reports
whoever wrote the driver: Honeykrisp is Mesa's 0x10005, not Apple's 0x106B.
OpenGL matches on GL_RENDERER for the same reason -- an Intel Mac reports
vendor "Apple Inc." with an AMD GPU. Mali is deliberately left out of the
OpenGL gate; the Vulkan path opts it out for early-ZS, but that has not been
tested on a Mali GL driver.

The Metal change is uncompiled -- those translation units only build on
macOS.
2026-07-25 19:02:01 -07:00
Brian Degenhardt 187cb90287 GS: assert nothing rewrites clear state behind the scheduler
The flush wrappers guard what a deferred draw can be reordered past. Nothing
guarded what a deferred draw can be made to lie about: a queued draw has not
run yet, but GSTexture::m_state says whether its target still owes a clear,
and rewriting that behind the scheduler's back moves the clear to the wrong
side of the draw. That is invisible to a frame hash until it corrupts, and it
is the shape of both bugs this design has produced so far - m_state going
stale during deferral, and Recycle() parking a texture a queued draw named.

So assert it directly, in SetState/SetClearColor/SetClearDepth. The scheduler
itself rewrites this state by design, hiding a pending clear at enqueue and
restoring it at emit, so it gets an explicit bypass rather than an exemption
the assert has to guess at.

Placement was measured, not assumed. The first attempt put the tripwire on
the Vulkan image layout transition, on the theory that it sees every path to
a texture. It caught nothing: deleting FlushDeferredDrawsFor() entirely
corrupts Dirge of Cerberus, and the layout-transition assert stayed silent
through all of it, because ClearRenderTarget/ClearDepth/InvalidateRenderTarget
only touch CPU-side state and never reach the GPU at all. Moving the assert
into GSTexture catches that same control on the first frame, and covers all
six backends instead of one. The Vulkan assert stays as the GPU-side half,
which no longer has to carry a job it cannot do.

Devel-only; the corpus is unchanged and silent with it armed.
2026-07-25 18:37:06 -07:00
Brian Degenhardt aeb2b63e81 GS: route texture mapping and mipmap generation through the flush point
Update() already flushed queued draws before uploading, but the two other
paths that write texture contents from outside the device did not: Map(),
which the texture cache uses to stream uploads straight into a surface, and
GenerateMipmap(), which reads every level and writes the smaller ones.

Map() takes the same non-virtual wrapper treatment as the GSDevice entry
points - the backend override becomes a protected DoMap(), so a backend
cannot be reached without passing through the flush, and one that misses the
rename fails to compile as abstract. GenerateMipmap() needs no such change:
its only caller is the non-virtual GenerateMipmapsIfNeeded(), so guarding
that one site covers all six backends and leaves the overrides untouched.

Both use the narrow FlushDeferredDrawsFor(this) rather than a full flush, for
the reason Update() does - the overwhelming majority of uploads are into a
surface the queue has never seen.
2026-07-25 18:36:34 -07:00
Brian Degenhardt b9504ac752 GameDB: enable render-pass coalescing for Dirge of Cerberus
Dirge alternates 1:1 between a colour target and a mask target for most of the
frame, which is the exact pattern the scheduler exists for. On a four-frame
capture it takes the frame from 8035 render passes to 523, with every frame hash
identical to the unscheduled path, and on an Adreno 650 handheld the difference
is plainly visible.

All six regional serials get it. This goes in the mobile overlay rather than the
canonical GameIndex, because it is worth nothing on a desktop GPU where a pass
boundary is cheap - the overlay ships on Android, iOS and ARM64 Linux handhelds,
which is exactly the set of targets that pay for tile load and store.
2026-07-25 18:17:24 -07:00
Brian Degenhardt 44e950e7d5 GS: expose render-pass coalescing in the settings UI and GameDB
Coalescing has only been reachable by hand-editing the INI. It needs to be
switchable per game, because whether it is worth anything depends entirely on
the title: it pays off when a game alternates between two render targets, and
does nothing at all otherwise.

Add the GameDB key coalesceRenderPasses, so a game that benefits can turn it on
by itself, and a checkbox in Graphics > Advanced plus a Full Screen UI toggle
next to the other driver-level GS options, so it can be tried on anything.

It classifies as a user-hack fix, which means enabling Manual Hardware Renderer
Fixes turns it back off - the usual escape hatch, and worth having while this is
new. Note the consequence for A/B testing: GameDB is applied after settings are
loaded, so for a game carrying the key, -set cannot switch it off.
2026-07-25 18:17:16 -07:00
Brian Degenhardt f2213660f0 GS: only flush queued draws for an upload the queue can see
GSTexture::Update drained the whole deferred-draw queue before every upload.
Almost none of those uploads touch a texture the queue has heard of - the
texture cache uploads into surfaces it just fetched from the pool - so the
flush was throwing away coalescing for nothing.

Use the narrow form, which flushes only when a queued draw actually reads or
writes this texture. That is the same test the pool and deferred-clear paths
already use.

Attributing every flush in a four-frame Dirge of Cerberus capture put uploads
at 46 of 305, second only to draws that carry a barrier. Removing them takes
the capture from 543 render passes to 523; the rest of the corpus is unmoved
and every frame hash is still identical to the unscheduled path.
2026-07-25 17:56:02 -07:00
Brian Degenhardt 8c702e0d1d GS: let the pool see the textures the scheduler is holding back
Recycle() parks a texture that a queued draw still names instead of returning it to
the pool, and FetchSurface did not know about that list. A request the parked texture
would have satisfied fell through to CreateSurface, so the working set stayed one
surface larger for the rest of the frame, and alternating onto that extra target cost
pass boundaries the unscheduled path never paid.

Diagnosed by probing the pass sequence at the counter itself: the two arms agreed
exactly on every reordering metric - 103 same-attachment rebinds, 96 feedback flips,
88 feedback passes - and differed only in using 12 distinct render targets where the
baseline used 11.

Scan the parked list before the pool and flush only when the surface being asked for
is in it. An unconditional flush here is the thing the scheduler exists to avoid.

  God of War II   513 -> 506 render passes over four frames (unscheduled: 506)
  MGS3            391 -> 388                                (unscheduled: 388)
  Dirge           547 -> 543                                (unscheduled: 8035)

Frame hashes remain identical to the unscheduled path across the whole dump corpus.
2026-07-25 17:36:50 -07:00
Brian Degenhardt ef10efb3c7 GS: flush only for textures a queued draw can actually see
Most of what remained of the flush rate was bookkeeping, not hazards. Texture
pooling and deferred-clear state run several times a frame against textures the
queue has never heard of, and draining the whole queue for those threw away most
of the coalescing.

ClearRenderTarget, ClearDepth, InvalidateRenderTarget and Recycle now take the
narrow form, which flushes only when the scheduler is holding a draw that reads
or writes that texture. Recycle goes further and holds the texture back instead,
returning it to the pool once the queue drains - the texture cache has already
dropped its reference, so the queue is the only thing that can still name it,
and keeping it out of the pool is what FetchSurface's flush was really for. That
flush therefore goes away entirely.

With the queue living longer, a third and fourth target come into view, so raise
MAX_RUNS to four. Eight measures no better.

Dirge of Cerberus, dump replay: 72.4 flushes and 139 render passes per frame,
down to 21.6 and 67. Frame hashes identical across the whole dump corpus.
2026-07-25 17:28:20 -07:00
Brian Degenhardt 82b862580c GS: let the second-pass draws defer as well
alpha_second_pass and blend_multi_pass were rejected on the theory that they
re-read the target between their own passes. They do not: both are extra draws
the backend issues inside the same RenderHW call, into the same attachments,
with different pipeline state and the same geometry - verified in the Vulkan
and OpenGL backends, neither of which ends the render pass for them. The whole
config copies by value, so they ride along with the record for free.

Only their own barrier requests still disqualify a draw, for the same reason
the primary one does.

This was the single largest source of forced flushes on the Dirge of Cerberus
dump - 37.9 of 72.4 per frame.
2026-07-25 17:28:20 -07:00
Brian Degenhardt 33e7def66f GS: keep target state visible while draws sit in the scheduler
GSTexture::State is not private to the backend. The texture cache reads it in
seven places to decide whether a target has been written to yet, and the
backend flips it to Dirty at the moment it picks the attachment's load op -
which, for a deferred draw, is much later than the game asked for it. A queued
target therefore looked Cleared or Invalidated to the texture cache for the
whole deferral window, and it took the wrong branch.

Apply the transition at enqueue instead, so the window is unobservable, and
stash the original state on the run to hand back just before emitting it - the
backend still has to see Cleared or Invalidated to choose the right load op,
and it sets Dirty again itself.

Caught by the dump-corpus frame hashes: FlatOut 2 diverged with a single open
run, where deferral is supposed to be identity by construction, and Katamari
Damacy diverged once two runs could reorder.
2026-07-25 17:28:20 -07:00
Brian Degenhardt 923da1b811 GS: coalesce across a target alternation with two open runs
Lets the scheduler keep one run open per target instead of one in total, which
is what actually collapses a ping-pong. Draws to target A accumulate in one run
while draws to target B accumulate in another; at flush each run is emitted
contiguously, so the alternation costs two render passes instead of two per
draw pair.

Reordering between runs is only legal while the runs cannot observe each other,
so three checks force a flush instead:

  - Read-after-write: the incoming draw samples a texture that is an attachment
    of an open run.
  - Write-after-read: the incoming draw writes a texture some queued draw
    samples. This fires even when the draw is joining the run that owns that
    attachment, because the queued reader may be in the other run.
  - Attachment overlap: a new run may not share rt or ds with an existing one.
    A partial match, such as two colour targets sharing a depth buffer, is
    exactly the aliasing write that cannot be reordered.

Order within a run is never changed, so same-target results are untouched.

Measured with gsrunner over the .gs dump corpus, off vs on, every frame hash
identical in both arms:

  Dirge of Cerberus   8035 -> 1117 render passes   (-86%, ~1004 -> ~140/frame)
  God of War II        506 ->  506
  FlatOut 2            416 ->  416
  Katamari Damacy       82 ->   82
  MGS3                 388 ->  388
  Ratchet & Clank UYA   41 ->   41

The unchanged titles are the expected result, not a failure: they do not
alternate targets, so their draws flush straight through.

Dirge lands at ~140 passes/frame rather than the handful the pattern suggests,
so something is still forcing a flush around 17 times per frame. Worth chasing,
but it is a tuning question on top of a working reduction.
2026-07-25 17:28:20 -07:00
Brian Degenhardt 1620453785 GS: add the render-pass scheduler, one open run
Introduces GSPassScheduler, which holds hardware draws in a queue instead of
handing them straight to the backend, and emits them when something needs to
observe the target. This is the plumbing only: with a single open run the
queue can only ever hold draws that are already consecutive and already share
a render pass, so GPU order is identical to before by construction. Coalescing
across a target alternation - the point of the exercise, and where the win on
a tiler is - needs a second open run and lands separately.

Deferral copies the draw config and, importantly, its geometry: config.verts
and config.indices point into GSState's per-draw buffers, which the very next
draw overwrites. Records index into two vectors rather than holding pointers,
since those vectors reallocate as a run grows; they are never shrunk, so a
scene reaches its high-water mark and then stops allocating.

Only "plain" draws are deferred - no barrier, no feedback loop, no destination
alpha, no colclip, no second pass, no drawlist. Everything else renders
immediately after flushing, so a game that never ping-pongs targets keeps
exactly today's behaviour.

Gated on EmuCore/GS/CoalesceRenderPasses, default off, deliberately not in the
restart set: toggling it just stops deferring.

Verified over the .gs dump corpus (Dirge of Cerberus, God of War II, FlatOut 2,
Katamari Damacy, MGS3, Ratchet & Clank UYA) with gsrunner: every frame hash and
every render-pass count identical between off and on. On Dirge, 96.5% of draws
take the deferred path and the longest run reaches 204 draws, so the copy, the
run-key match and the flush hooks are all genuinely exercised.
2026-07-25 17:28:20 -07:00
Brian Degenhardt 1cbbe8b999 GS: route texture upload and readback through the flush point
The GSDevice entry points are not the whole story. A CPU upload into a texture
goes through GSTexture::Update and a readback goes through
GSDownloadTexture::CopyFromTexture, neither of which is a GSDevice method, so
both would let a deferred draw be reordered past work that must not move:

  - Update into a target a queued draw writes, or into a texture a queued draw
    samples. In the original order the draw sees the old contents; deferred
    past the upload, it would see the new ones.
  - CopyFromTexture is the actual readback - CreateDownloadTexture only
    allocates, and the texture cache reuses those - so guarding creation would
    not have covered it.

Same treatment as the device entry points: the virtual becomes a protected
Do* form and the public name becomes a non-virtual wrapper that flushes first,
which the pure base virtual makes compiler-enforced across all six backends.

Still unguarded and left to the debug tripwire: GSTexture::Map and
GenerateMipmap. Map cannot get the same mechanical rename because
GSDownloadTexture declares an unrelated Map in the same headers, and neither
is on a path that writes a render target today.
2026-07-25 17:28:20 -07:00
Brian Degenhardt 518418b3ac GS: route texture-touching device work through a flush point
Preparation for render-pass coalescing, which needs to hold hardware draws
back and emit them later, grouped by target. That is only safe if nothing can
observe a render target between a draw being submitted and that draw actually
running, so every GSDevice entry point that reads or writes texture contents
has to get the chance to flush first.

Rather than maintain that as a list of call sites - there are ~130 in the HW
renderer and texture cache alone - make it structural. The virtuals that do
the work move to a protected Do* form, and the public name becomes a
non-virtual wrapper that calls FlushDeferredDraws() first, following the
DoStretchRect / DoMerge / DoApplyShaderChain convention already used here. A
caller holding a GSDevice* then cannot reach the backend without passing
through the flush, and since the base virtuals are pure, a backend that misses
the rename fails to compile as abstract rather than silently skipping the
guard.

Converted: CopyRect, DrawMultiStretchRects, UpdateCLUTTexture,
ConvertToIndexedTexture, FilteredDownsampleTexture, RenderHW, BeginDSAsRT,
BeginPresent, HintReadbackSource. The base-class non-virtual operations take
the guard inline instead: the clears, Recycle and the texture pool, the whole
StretchRect family via its single root DoStretchRectWithAssertions, and the
present-path effects.

Backends call their own Do* directly where they already called themselves -
the RT clone VK/OGL/DX11/DX12 do from inside RenderHW for a feedback loop - so
emitting a deferred draw does not re-enter the flush.

FlushDeferredDraws() is empty here and the scheduler lands next, so there is
no behaviour change.
2026-07-25 17:28:20 -07:00
Brian Degenhardt 1daeee29d5 SDL: report argument errors on stderr instead of into the void
ParseCommandLineArgs reported bad arguments through Console.Error*, but argument
parsing runs before the console and file log sinks exist, so the message reached
neither the terminal nor emulog.txt. An unrecognised flag exited silently with
no diagnostic anywhere, which reads as a crash.

Write them to stderr, as --help in the same function already does. The unknown-
argument case also names the trap it exists to catch: this frontend takes none
of the Qt frontend's flags, so -fullscreen or -bigpicture land here, and both
are things it already does unprompted.
2026-07-25 17:10:09 -07:00
Brian Degenhardt ec57f7f1c6 GS: stop forcing an RT feedback read for Ad-masked blends on fbfetch
A destination-alpha blend with alpha writes masked (blend_c == 1, !colormask.wa)
was given require_one_barrier wherever framebuffer fetch was available, on the
reasoning that fbfetch makes the feedback read cheap. The read is cheap. The
render pass is not: binding the target as an input attachment changes the pass
configuration, and OMSetRenderTargets ends the pass every time that flag flips.

NFS Underground flips it ~697 times a frame against only 40 real target
switches, so nearly every pass boundary in the frame came from this.

Drop the framebuffer_fetch term, leaving only the no-texture-barrier case where
the fallback already copies the RT and no pass boundary is at stake. That term
was ours; upstream gated this path on texture_barrier (later texture_barrier ||
multidraw_fb_copy) until it was made unconditional, and never on framebuffer
fetch. On turnip, which exposes the EXT spelling of the rasterization-order
extension, our term switched the path on for exactly the drivers where a pass
boundary is the dominant cost.

  Adreno 650   NFSU        440 -> 64 passes/frame, 27.6 -> 7.6 ms
               FlatOut 2   954 -> 102,             37.2 -> 16.4 ms
  Mali-G52     NFSU        746 -> 69,              276.6 -> 96.8 ms
               FlatOut 2   892 -> 96,              373.6 -> 145.0 ms

Correctness is unaffected: Ad blends that genuinely need software blending are
still forced into it by blend_requires_barrier, which tests blend_ad against the
RT alpha scaling separately. Scored per-pixel against the software renderer both
GPUs came out more accurate, not less - on Adreno the worst-case error in NFSU
halves, 19 to 8, with no pixel off by more than 16. Katamari, MGS3 and Ratchet &
Clank are bit-identical on both.
2026-07-25 17:10:06 -07:00