Commit Graph
100 Commits
Author SHA1 Message Date
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
Brian Degenhardt c4d0a8a47c EE: record why recRAMCopy is sized to MainRam, not ExposedRam
Upstream x86 sizes the snapshot buffer, its guard and its LUT to
Ps2MemSize::ExposedRam, which follows the 128MB devkit mapping; ours uses
MainRam. That looks like a portable one-line divergence and is not: this rec
is MainRam-only throughout — recLutEntries and the recRAM advance in
recReserveRAM, the (MainRam / _64kb) alias mask in recResetRaw that folds all
guest RAM into a 32MB LUT window, and the manual_page / manual_counter
arrays.

Widening the buffer on its own would hand two blocks aliased to the same LUT
entry two distinct snapshots, which is worse than the coverage it buys. Note
the constraint at the allocation so the next reader does not try it.

The real gap — that this rec does not support the 128MB mapping at all —
stands, and needs the LUT, the mask and the manual-page arrays converted
together.

Comment only, no behaviour change. recompiler_tests 1439/1439.
2026-07-25 16:06:48 -07:00
Brian Degenhardt 2286e47027 EE: assert no block survives a recClear of its own range
recClear's walk is supposed to leave nothing behind that overlaps the range
it just cleared. Upstream x86 checks exactly that and calls a violation
"Impossible block clearing failure" (iR5900.cpp); our port dropped the check
when it was written. It is worth having: the straddler-from-below that
3ac41e05bd fixed is precisely a block that survives such a clear, so this
would have caught that bug and will catch the next one in the same class.

Upstream's version was not idle either — 801d71f7f0 (2009) added the check
and the unsound early break that defeats it in the same commit, so the
condition it declares impossible has been reachable there ever since.

Gated on PCSX2_DEVBUILD rather than unconditional, and the scan is bounded
instead of walking every live block on every clear: a survivor overlapping
[addr, end) has to start within s_maxBlockBytes below addr, so the window is
the same size the walk itself already covers. recClear runs from the fastmem
SIGSEGV handler, where an O(live blocks) sweep per fault is not something to
add lightly.

Verified non-vacuous by restoring the old unconditional break in the walk:
the assertion fires, on OverlapWalkScansPastNonOverlappingNeighbor, which is
the gate written for that bug.

recompiler_tests 1439/1439.
2026-07-25 16:06:34 -07:00
Brian Degenhardt 47614daa67 IOP: scan past non-overlapping neighbours when hunting straddlers
psxRecClearMem walks down through recBlocks from the block containing the
written word to pick up straddlers — blocks that start below it but whose
bodies reach into the range being cleared. recBlocks is ordered by startpc,
not by end address, so stopping at the first block that ends before the
write is unsound: a longer block lower down can jump clean over a short one
and still cover the write, and the scan quits before it is ever examined.

The straddler is then neither removed from recBlocks nor LUT-reset, so it
keeps executing stale code. This is the IOP half of the same blind spot
3ac41e05bd fixed on the EE; both stops came from 801d71f7f0 (2009), which
added them to iR3000A and iR5900 in one commit and is still upstream in
both.

Scan past a non-overlapping neighbour instead of stopping on it, bounded by
s_maxBlockBytes — the longest guest extent compiled since the last reset, so
the walk still terminates as soon as nothing below could reach. The scan
cursor is split from the removal start, so a skipped survivor never becomes
the base of the removal range.

Unlike the EE, IOP blocks have no 4KB page break (psxRecRecompile ends them
only at a branch or an already-compiled head), so the mark has no structural
cap beyond the 0xffff-instruction assert. IOP blocks are short in practice;
the cost is flat-array reads either way.

New gate ClearScansPastNonOverlappingNeighborToStraddlerBelow builds the
geometry the bug needs — A long, H compiled mid-A, then B truncated against
H's compiled head, which is the only way to get a block that both starts
inside A and ends before A does. It pokes a word inside A past B's end and
requires A to run the new instruction. Red before this change with the JIT
returning the stale 0x11 against the interpreter's 0x22, green after.

recompiler_tests 1439/1439.
2026-07-25 16:06:14 -07:00
Brian Degenhardt 4c70e6ecd3 tests: establish shared state instead of inheriting it
Three EE tests asserted on process-global emulator state they never set up.
They passed in declaration order because their neighbours happened to leave
that state alone, and failed under --gtest_shuffle. Same class as the overlap
tests in cf403dbd8a, different state.

Guest RAM (ee_rec_carbon_selfloop_store_tests). The self-loop tests all fill
kDataAddr, at different lengths — ManyIterations writes 100 bytes — and
PinnedValueLoopCarriedBaseByteFill asserted the byte one past its 8 was zero.
Nothing zeroes guest RAM between tests, so in any order but the declared one it
read a neighbour's leftover 0xAB. Seed the window with a 0x5A guard first and
assert the guard survives. That is also strictly stronger than expecting zero:
the ExactGameEntryState shape stores zeros, so "expected 0" could not tell an
overrun from a clean run at all. Every test in the file now seeds, and the
three that lacked a one-past-end assertion gained one — an off-by-one in the
loop trip count is exactly the bug this file exists to catch.

INTC_STAT (ee_rec_loadstore_tests). Five tests read 0x1000F000 across a vtlb
seam and assumed it reads 0. It is write-1-to-clear and process-global, and
hwIntcIrq is just `psHu32(INTC_STAT) |= 1 << n`, so any test that raises an
interrupt leaves its bit set and a guest store of 0 cannot clear it. Under
shuffle these read a stray 0x40 — bit 6, INTC_VU0, left by a VU test. Clear it
host-side before the run, where W1C does not apply, at all five sites.

Both fixed tests also poison their load destination (0xDEADBEEF) so the
expected-zero results prove the load actually wrote the register rather than
finding it already zero.

Verified non-vacuous by injecting the faults they are meant to catch: with the
trip count raised to 9 the guard assertion fires (0xAB vs 0x5A), and with the
LWC1 removed the poison reaches the assertion — confirming the harness does not
zero FPRs, so the poison is real coverage and not decoration.

recompiler_tests 1438/1438, and clean across 40 --gtest_shuffle seeds where
these three previously accounted for 52 failures.

Still outstanding, pre-existing and unrelated: one seed in 40 fails a cluster of
EeVu0*/EeRecPinnedGpr COP2 tests on carried-over VU0 special registers (vi26
TPC, vi29 VPU_STAT) — the JIT and interpreter take different paths from a stale
VPU_STAT, so the harness's VU0 diff reds. Reproduced identically at 450d5e5e28.
2026-07-25 14:30:08 -07:00
Brian Degenhardt cf403dbd8a tests: make the EE overlap-walk tests self-sufficient
OverlappingCompileClearsStaleBlock and OverlapWalkIgnoresUnmodifiedNeighbors
passed only in full-suite order; run alone, both failed with the JIT taking a
different path through the routine than the interpreter.

Not a JIT bug. Both drove the routine through a caller holding a direct JAL,
and re-pointed it between Run()s by calling LoadProgram again with a new
target. Nothing invalidates that caller: the harness wires no page-protection
SIGSEGV, so on a PreserveCache Run a rewritten program is re-compiled only if
the page has reached ProtMode_Manual and its blocks carry the inline entry-time
SMC check. Promotion needs manual_counter to build up over repeated clears
(memory_protect_recompiled_code), which only happens once some earlier test has
hammered the program page — every preceding test that runs the JIT was priming
it. Alone, the stale caller ran and called its old target.

Call indirectly instead: JALR through t1, seeded per Run, so the caller block
is compiled once and never needs invalidating, and the entry point varies by
register rather than by rewriting code. This is the pattern
MidCompileOverlapClearKeepsLutAndLinkerCoherent already uses and documents. The
per-step sentinel goes away with it — one fixed value still separates "the
routine's ADDIU ran" from "we entered past it".

OverlapWalkIgnoresUnmodifiedNeighbors also now asserts what its name claims.
It only checked v0, which its own comment conceded proves nothing: a cleared
and recompiled head block returns 9 exactly like an untouched one. It now
captures the head block's fnptr before the overlapping compile and requires it
to survive unchanged — blocks are bump-allocated and never reused before a full
reset, so an unchanged fnptr means that compile is still live. Verified
non-vacuous by forcing the walk's memcmp to always mismatch: the new assertion
fires, and it is the one that fires.

All 8 EeRecSmc tests now pass individually as well as in-suite;
recompiler_tests 1438/1438.

Unrelated and pre-existing: EeRecCarbonSelfLoop.PinnedValueLoopCarriedBaseByteFill
and two EeRecLoadStore tests fail under --gtest_shuffle. Reproduced identically
at 450d5e5e28, before either of these commits.
2026-07-25 13:20:13 -07:00
Brian Degenhardt 3ac41e05bd EE: scan past non-overlapping neighbours when hunting stale blocks
Both the stale-overlap walk at the tail of recRecompile and recClear
descend recBlocks and stop at the first block that ends before the range
they care about. recBlocks is ordered by startpc, not by end address, so
that stop is unsound: a long block at a low address can jump clean over a
short one lying between it and the range, and the scan quits before the
long one is ever examined.

The walk then misses a genuinely stale block, and recClear leaves it in
the cache — the straddler-from-below it is specifically written to remove.
Reachable whenever a block goes stale AFTER its neighbours compiled, which
is exactly the ordering the walk exists for: an earlier compile's own walk
would otherwise have cleared it.

Scan past a non-overlapping neighbour instead of stopping on it, bounded
by s_maxBlockBytes — the longest guest extent compiled since the last
reset, so the scan still terminates as soon as nothing below could reach.
recClear splits its pending remove range around a skipped survivor exactly
as it already does for s_pCurBlock.

recClear also drops its lowerextent floor clamp. A skipped survivor can
sit inside a removed straddler's extent, and raising the floor to that
survivor's end would leave the straddler's own start word pointing at its
removed stub — the fnptr assert StraddlerBlockRecClearResetsStartFnptr
pins. Resetting a survivor's LUT entry instead costs it one recompile.

This was inert until f4debc54c1: with recRAMCopy indexed at startpc/4 the
compare mismatched on essentially every overlapping pair, so the walk
always cleared before under-scanning could cost anything. A working
compare makes the blind spot load-bearing.

Final Fantasy X (SLUS-20312), 300 frames from an in-game savestate, with
the scan instrumented — behaviour is unchanged and the added work is
noise:

                    before    after
  recompiles          2642     2642
  blocks removed       745      745
  stale found            0        0
  walk iterations     5239    19377
  recClear iters       811     1113
  max block extent   880 B    880 B

~5 extra loop iterations per block compile, ~0.004% of EE-thread cycles;
EE-thread CPU time 0.58 s both ways. The high-water mark stays far under
the 4 KB the page-boundary rule implies, so the bound is tight in practice.

recompiler_tests 1438/1438. New gate OverlapWalkScansPastNonOverlappingNeighbor
builds the A/B/N geometry (long A, short B truncated against C, new N above
B's end), pokes A stale after B and C compiled, and asserts A is cleared;
it fails on the walk fix alone, so both halves are pinned. Upstream x86
carries the same stop in both places (iR5900.cpp:786, :2688).
2026-07-25 12:40:18 -07:00
Brian Degenhardt 450d5e5e28 mVU: pack MAC/status flags with one ADDV instead of two movemask chains
mVUupdateFlags was a 1:1 port of x86's double-MOVMSKPS extraction: two NEON
AND/ADDV/UMOV chains glued together with GPR AND/SHL/AND/OR, plus a per-site
weight vector emitted through vixl's literal pool. That is 16 instructions of
flag packing around the 2 instructions of real FMAC work, and it measured as
~29% of all VU1 code time in God of War II - the first VU-bound title we have
profiled. One VU1 program carried 1666 literal-pool slots holding 34 distinct
values, 12.8% of its host code, each on its own cache line.

SLI collapses it. CMLT and FCMEQ both yield all-ones-or-zero lanes, so

    sli vZero.4s, vSign.4s, #4

leaves sign in bits [31:4] and zero in [3:0] of one register, and a single AND
against a combined per-lane weight selects lane i's sign into bit (i+4) and its
zero into bit i. The per-lane bit sets are disjoint, so ADDV's sum is an OR.
Seven instructions replace sixteen, in the two temporaries the caller already
had - the weight vector may reuse the sign register, which is dead by then.

Because the weight vector is picked at emit time it also absorbs AND_XYZW and
SHIFT_XYZW, which stop existing as instructions, and the vectors move out of
the literal pool into mVUglob.macWeights so they ride the pinned gprMVUglob
base as a single Ldr and share a few D-cache lines. The status merge folds the
same way: x86's `AND mReg, 0xFF` is provably a no-op off the overflow path, and
the non-sticky copy becomes an ORR shifted operand.

Same rewrite for the COP2 macro path in cop2EmitFlagUpdate, which loads its
weights as a literal - that path deliberately leaves gprMVUglob unpinned, since
x25 is the EE recompiler's RECCYCLE inside an EE block. mVUupdateFlags now
asserts it is micro-mode only. The parked-result copy in q28 goes away too: the
pack only touches q29/q31, so a result in RQSCRATCH survives untouched.

Measured with pcsx2-vurunner on M2 over 323 VU programs: host instructions
-22.8%, cycles -20.0% (median per-program -17.1%, best -34.3%, no program
regressed by more than 0.5%). Emitted host bytes -19.6% to -40.3% across
Burnout 3, R&C UYA, Shadow of the Colossus and Katamari - the I-cache side
should matter more on the SD865's 64 KB L1I than it does here. All 5818 corpus
captures replay bit-identically against the interpreter, before and after.

vu_mac_flag_pack_tests pins the weight table against the interpreter across the
three shapes that select different table rows (full mask, partial mask, and the
single-scalar rotate that is the only folded non-zero shift).

kMvuCompilerAbiVersion 15 -> 16: every flag-writing FMAC changes shape, and the
emitted [x25, #imm] weight offsets only exist in an mVUglob layout carrying
macWeights, so on-disk program caches must evict.
2026-07-25 10:52:04 -07:00
Brian Degenhardt f47e207912 GS/HW: skip the PS2 Z floor where it is provably an identity
God of War II's Athena statue speckles with bright blue pinpoints on Apple
GPUs. The statue is drawn as paired passes over identical geometry: an
env-map layer that writes depth, then a stone layer that re-tests the same
triangles with GEQUAL and no depth write. The second pass only lands because
its depth matches what the first pass stored.

zfloor is set on the depth-writing pass, so that pass emits gl_FragDepth =
floor(z * 2^32) * 2^-32 while the read-only pass tests against fixed-function
interpolated depth. But a float32 ULP at depth z is 2^(exp(z) - 23) and a PS2
Z unit is 2^-32, so from z >= 2^-9 -- integer Z >= 2^23 -- the two are the
same size and the floor cannot change any value the draw produces. The statue
sits at z ~ 0.00196, inside that range.

So the floor does nothing arithmetically there; all it does is move the depth
write onto the gl_FragDepth path. The two paths need not agree bit-for-bit,
and where they don't the GEQUAL retest drops out and the env-map layer's
saturated blue survives as a pinpoint. Skipping a provably-identity floor
keeps the arithmetic identical, and removing gl_FragDepth restores early-ZS
for that class of draw -- what the no_ps2_z_quantization escape hatch was
reaching for, without giving up the floor's real job on ZTST_GREATER.

Measured with gsrunner over three GoW2 dumps, software renderer as oracle.
Sparkles in the statue box: 220 -> 1 (the remainder is present in the clean
control too). Whole-frame divergence from SW across all four frames of each
dump: 2937 -> 698, 1624 -> 897, 3046 -> 874 -- every dump moves closer to the
reference. Blending is uninvolved: -accblend 0 and -accblend 5 both leave the
count at 220, as do -no-fb-fetch and -no-tex-barriers.

Reproduces on M2 under Asahi via Vulkan, so this is Apple GPU silicon rather
than the Metal backend; Snapdragon 865 is unaffected. In-game fix confirmed.
2026-07-25 10:40:02 -07:00
Brian Degenhardt 5da1d59568 gsrunner: capture GS dump frames with RenderDoc
Add -renderdoc <path> and -renderdoc-frame N[,C], writing one .rdc per selected
dump frame.

RenderDoc's own triggers cannot reach gsrunner on a Wayland session. It polls
only X11/xcb for the capture key, so PlatformHasKeyInput() is false and F12 is
never seen. Target control cannot drive it either: RenderDoc hooks only the core
EGL entry points, while GLContextEGL prefers eglGetPlatformDisplayEXT and
eglCreatePlatformWindowSurfaceEXT, so no native window is ever registered for the
surface and there is nothing to attach a capture to. The in-application API
sidesteps both -- StartFrameCapture(nullptr, nullptr) captures the active device
whatever the windowing system, and needs no keypress, so a headless dump replay
can capture unattended.

Captures open and close at present boundaries in Host::BeginPresentFrame(), which
runs on the GS thread with the frame's work submitted but not yet presented.

RenderDoc must already be in the process, since it installs its hooks from its
library constructor. A late dlopen hands back a working API whose hooks were
never installed and then captures nothing, so that case is refused with the
LD_PRELOAD command to use instead.

Verified on aarch64/Asahi against a God of War II dump. Vulkan surfaceless
produces a valid capture: 351 actions, 218 draws, 99 textures, render target
carrying real image data. Windowed Vulkan cannot work under RenderDoc's layer at
all, which does not advertise VK_KHR_wayland_surface. GL captures record correct
events and texture contents but replay with black render targets on Honeykrisp.

3rdparty/include/renderdoc_app.h is RenderDoc's MIT-licensed in-application API
header, vendored verbatim.
2026-07-25 09:43:57 -07:00
Brian Degenhardt f4debc54c1 EE: index recRAMCopy by guest address, not startpc/4
The stale-overlap walk at the tail of recRecompile snapshots each
compiled block's guest bytes into recRAMCopy, then memcmps older
overlapping blocks against their own snapshots to catch code that went
stale through a write no protection path caught. recRAMCopy is a byte
array covering main RAM 1:1, but both the compare and the snapshot
indexed it at `startpc / 4`, packing every block's snapshot 4:1 into the
low quarter of the buffer.

Two blocks whose guest starts differ by N bytes then land only N/4 bytes
apart in the snapshot, so overlapping blocks scribble over each other's
snapshots and the compare can never match: each compile recClears the
other and both recompile forever.

Final Fantasy X (SLUS-20312) has such a pair at 0x002B9F48 / 0x002B9F5C.
Measured over 300 frames from an in-game savestate, EE thread:

  before   56,546,855,007 insns   14,591,559,685 cycles   6 cache resets
  after     5,885,593,412 insns    1,368,467,226 cycles   0 cache resets

9.6x fewer instructions, 10.7x fewer cycles. The EE thread had been
spending ~95% of itself inside the recompiler (vixl, register allocation,
vtlb_AddLoadStoreInfo, icache flushes) and only ~5% executing JIT code,
burning 51 MB of the 58 MB EE code cache every ~48 frames and taking a
full cache reset behind it.

The /4 was correct in 2009, when recRAMCopy was u32*. It became wrong in
d6de2e394 (2010), which swapped the storage to SpatialArrayReserve whose
operator[] yields u8&, and it survived every later refactor because both
index sites carried it consistently. Upstream x86 iR5900.cpp:2691/2701
still has it. The failure is conservative — it over-clears, never misses
a genuinely stale block — which is why it stayed invisible for this long
as a pure performance bug.

Also clamp the compare length so a block straddling the top of main RAM
cannot overread: correct indexing now actually reaches the top of the
buffer, where the /4 could never go.

recompiler_tests 1432/1432, including the three EeRecSmc overlap-walk
tests that pin this path. eerunner --stepdiff on FFX is unchanged before
and after (the pre-existing divergence at 0x002ce6d4 reproduces
identically on both).
2026-07-25 09:18:33 -07:00
Brian Degenhardt 540c5ae0c5 eerunner: --gsdump headless GS-dump capture
Records the GIF command stream from a --liverun into a .gs dump, so a GS
workload can be replayed in pcsx2-gsrunner with no emulator in front of it.

This is the only honest way to A/B the GS across two builds. Measuring the
GS inside a live run does not work: both the MTGS ring and the software
rasterizer's job queue spin while waiting for work, so a build with a faster
EE shifts the GS threads' instruction counts without changing a single pixel
of GS work. Measured on Xenosaga with byte-identical GS workloads (same PRIM,
DRW and Mpps), a live run reported the GS threads executing 23% more
instructions while burning 21% fewer cycles -- the signature of spin, not
rasterization. Replaying the same captured dump standalone put the same build
11% ahead on instructions.

Recording arms after --gsdump-at frames (default 30, so the scene has settled)
and stops on its own once the requested frame count has been recorded; the
frame budget is raised automatically if it would truncate the dump.
2026-07-24 21:04:35 -07:00
Brian Degenhardtandpstef fd1fa89e10 IOP: route JIT RAM stores through out-of-line fast-path stubs
After the removal of the per-store block search, the remaining per-store cost
was the C call chain itself: iopMemWrite* region dispatch + WLUT load, the
indirect psxCpu->Clear call, and the recClearIOP/psxRecClearMem frames --
~2.9% of battle EE-thread samples in pstef's profile (iopMemWrite32 1.13%,
iopMemWrite16 0.28%, recClearIOP 1.44%).

Emit three per-width stubs (_DynGen_StoreStub) alongside the dispatchers
instead: gate, IsC check, direct store into Main[addr & (ExposedIopRam-1)],
and an inline g_iopCodeCov granule probe that only calls into the clear
machinery (iopStoreClearHit -> recClearIOP) when a live block overlaps the
store. Store sites emit a single BL, exactly the size of the old C call.
Hw/unmapped targets tail-jump from the stub to iopMemWrite*, which returns
straight to the store site; compile-time-known hw addresses keep calling C
directly.

The condition is (addr & 0x1f800000) == 0, not a bit-28 test: iopMemWrite*
masks addresses to 0x1fffffff, and within that space pages 0x00-0x7f are the
only WLUT-mapped write target reachable this way (plain RAM; the parallel port
at 0x1f00 is the other mapped page and the mask excludes it). Everything else
-- 0x1f80/0x1f40 hw, SIF, DEV9, SPU2, ROM, and the unmapped
0x00800000-0x0fffffff range that bit 28 alone misclassifies as RAM -- must
take the C path. Bits 23-28 survive the phys mask, so the test is exact for
every KUSEG/KSEG0/KSEG1 mirror, and matches iopMemReset's `for (i < 0x0080)`
mapping loop. The RAM mask equals the recLUT_SetPage mirror collapse, so the
masked offset doubles as the coverage probe index. IsC (Status bit 16)
swallows the store with no clear, matching the C RAM branch's
p != NULL && !IsC guard. Stubs are re-emitted on every recResetIOP, so the
baked mask and RAM base track extra-memory-mode flips (which discard all
blocks). RPSXSTATE is x21, callee-saved, so it survives both the BL and the
slow-path tail jump.

Outlining is deliberate: pstef measured a fully inline fast path (~15
insns/site) first, and its per-site code growth cost +10% L1I misses/frame,
eating the win (3D net zero, FMV only -1.08% cycles). The shared stubs keep
sites baseline-sized and also displace the C functions' own icache footprint.

Documented at the stub, not fixed here: the inline probe is mirror-collapsed
(addr & (ExposedIopRam-1)) while iopCovAdjust and psxRecClearMem key the same
array by HWADDR, which strips the KSEG base but not the RAM mirrors. Under a
2MB configuration a block executed from a mirror page registers coverage at a
different granule than a store to its physical alias probes. That is exactly
the pre-existing blindness of keying recBlocks by HWADDR -- not a regression
-- but the two domains are a trap for anyone making either side finer-grained.

Six new IopSmc cases: KSEG1-mirror, const-address, SB, and SH stores over a
pre-compiled victim block (compiled first, then stored into via RunResume, so
a stale-cache leak is actually observable), unmapped-low-region drop, and
IsC-swallow. Verified not inert: a bit-28-only gate turns
JitStoreToUnmappedLowRegionIsDropped red, and defeating the IsC check turns
six cases red including JitStoreWithCacheIsolatedIsSwallowed. Full suite
1432/1432.

Taken from https://github.com/yaps2/yaps2/pull/11 by pstef
(https://github.com/pstef).

Co-authored-by: pstef <3462925+pstef@users.noreply.github.com>
2026-07-24 21:02:50 -07:00
Brian Degenhardtandpstef b7e360b3c7 IOP: gate psxRecClearMem with a 256B block-coverage count array
Every qualifying IOP RAM store funnels through psxRecClearMem for SMC
detection, and in practice ~100% of them hit addresses no compiled block
covers -- yet each one paid a binary search over recBlocks to learn that.

Replace the unconditional search with s_iopCodeCov[]: u16 counts of live
blocks whose span overlaps each 256-byte granule of the HWADDR window. A zero
counter proves no block overlaps the written word, so psxRecClearMem returns a
skip to the next granule boundary in O(1); range clears (SIF1/CDVD/SPU2 DMA)
step per granule instead of per word.

Counting the full span rather than just block heads is what keeps mid-block
stores correct, and counts (not bits) stay exact when overlapping blocks are
removed independently. Counters are maintained at the three block-lifecycle
points -- size-finalize (+1), recompile of an existing entry (-1 old span),
psxRecClearMem removal (-1) -- and zeroed on recResetIOP. ROM-resident blocks
are never counted; stores cannot target ROM.

The error direction is the safe one: an over-count only costs a search that
early-outs, and only an under-count could miss a clear.

Two deltas from pstef's version, both from our tree having diverged:

- The recompile-reuse hook attaches differently. His iopRecRecompile splits
  Get()/New() and hangs the retirement off the else arm; ours calls New()
  unconditionally because ours re-binds an existing entry in place (the
  block-link owner-tracking rework, 9eb93d4dcd). Same case, so the stale span
  is read off the pre-New() entry instead. A never-finalized entry has size 0
  from insert()'s memset, which iopCovAdjust ignores.

- iopCovAdjust clamps glast to the last granule. It guarded the start against
  kIopCovSpan but not the end, so a block starting near the top of the window
  and running past it indexed off the end of the array -- caught by pxAssert
  on Devel, silent OOB store on Release. The clamped tail granules are
  unreachable by any store, since the probe index is masked into the window.

New regression tests: a block spanning a granule boundary must recompile when
its tail granule is written, and a KSEG-mirror store must clear the block it
aliases (pinning the HWADDR domain the fast path keys on). Verified the first
is not inert -- sabotaging iopCovAdjust to a head-only count turns it red and
leaves the other six IopSmc cases green. Full suite 1426/1426.

Scale expectation, measured here 2026-07-24: pstef reports +5.1% (battle) /
+13.0% (FMV) unthrottled VPS on Cortex-X1C/A78C. We have NOT reproduced those
figures. A 30 s perf capture of Dirge of Cerberus gameplay on the SD865
(Retroid Flip 2, ROCKNIX) puts recClearIOP -- the function holding the inlined
psxRecClearMem and its recBlocks search -- at 0.95% of total samples, so ~1% is
the whole budget this can recover in that scene, and the scene is GS-bound
(GS thread 40.4%, CPU thread 41.8%) so little of it converts to frame rate.
The win scales with DMA volume into IOP RAM (CDVD sectors, SIF1, SPU2), not
with IOP instructions retired, which is why streaming-heavy and unthrottled
scenes show it and steady-state 3D gameplay does not. Treat the FMV figure as
pstef's rig, not as an ARMSX2 number.

Taken from https://github.com/yaps2/yaps2/pull/11 by pstef
(https://github.com/pstef).

Co-authored-by: pstef <3462925+pstef@users.noreply.github.com>
2026-07-24 21:02:45 -07:00
Brian Degenhardtandpstef 8e0b6e04b4 arm64: limit the const-MMIO shortcut's FLUSH_PC to unmapped handlers
The const-paddr MMIO shortcut flushed cpuRegs.pc on every access. Only the
two unmapped-page handlers can raise a guest exception -- vtlb_Miss ->
cpuTlbMiss -> cpuException derives EPC from that pc -- so for every
registered hardware handler the per-access mov/movk/str was dead weight,
paid once per access in an MMIO burst. x86 never flushes pc at its MMIO
sites at all.

Add vtlb_IsUnmappedHandlerID() and flush pc only for those two handlers,
plus the forceEventTest path: EE-counter reads end the block with the
g_branch=2 event-test exit, and FLUSH_EVERYTHING (0x1ff) excludes FLUSH_PC
(0x200), so this seam's store is the only thing that gives DispatcherReg a
resume pc. ConstCounterReadEventTestExitResumesAtNextPc pins that carve-out;
EeRecTraps.LoadTlbMissInDelaySlotSetsCauseBdAndBranchEpc pins the retained
unmapped-handler flush.

The decision reads handler identity at compile time to settle a runtime
property, but introduces no new assumption: the shortcut already resolves
its callee from the same compile-time mapping and hard-codes a direct BL to
it, so a mapping change that could falsify the pc decision would already be
sending the call to the wrong handler. Noted at the site, since that
equivalence is the whole basis for the trim being safe.

Full suite 1423/1423.

Taken from https://github.com/yaps2/yaps2/pull/10 by pstef
(https://github.com/pstef).

Co-authored-by: pstef <3462925+pstef@users.noreply.github.com>
2026-07-24 18:34:15 -07:00
Brian Degenhardtandpstef b772d73abf tests/ee: pin LUT/BASEBLOCKEX coherence after a mid-compile overlap clear
The GE-18 stale-overlap walk fires recClear mid-compile. The walk spares the
in-progress block's BASEBLOCKEX, but the post-walk ClearRecLUT tail runs over
the removed straddler's full extent, which spans the in-progress startpc when
the straddler comes from below. If that wipes BLOCK(startpc)->fnptr with
nothing to restore it, the next dispatch recompiles into the surviving entry
and the linker's target diverges from the dispatcher's.

Add recEeBlockHostInfo() so a test can read both routes -- BASEBLOCKEX fnptr
plus host size, and the LUT dispatch target -- and assert they agree, then
pin the scenario: compile a straddler, poke its first word so its recRAMCopy
snapshot goes stale, compile a victim entered four bytes inside it, and check
after both that compile and a re-dispatch that the routes match, the victim
was not recompiled, and x86size did not grow.

This passes on the current tree. Our recRecompile publishes the LUT entry at
the start of the compile rather than at the end as x86 does
(x86/ix86-32/iR5900.cpp:2704), which is what made the wipe unrecoverable on
the tree this fixture came from; here recClear's `ceiling` clamp keeps the
tail off the in-progress entry. The fixture is a pin on that property, not a
bug repro -- so it carries an explicit inertness guard asserting the
straddler really was removed. Without it, a future change that stops the
overlap walk from firing would leave the whole test green and meaningless.

Taken from https://github.com/yaps2/yaps2/pull/10 by pstef
(https://github.com/pstef); the inertness guard is ours.

Co-authored-by: pstef <3462925+pstef@users.noreply.github.com>
2026-07-24 18:34:15 -07:00
Brian Degenhardtandpstef 8742617fe4 arm64: keep EE const marks across vtlb C-call seams
The vtlb load/store seams flushed const-tracked GPRs with delete_const=true,
so the first vtlb seam in a block destroyed every const mark. An MMIO burst
-- N accesses off one lui/ori const address -- then degraded accesses 2..N
from the const-paddr shortcut (a direct BL to the registered handler) to the
dynamic fastmem/softmem path. The DBZ3 GS-priv block at 0x0029474c (5x sd to
0x120000xx) emitted one BL plus four dynamic stores where x86 emits five BLs.

Add FLUSH_CONST_KEEP as a modifier on FLUSH_CONSTANT_REGS: write dirty const
values back to memory, but keep the tracking marks. FLUSH_VTLB pairs the two
and replaces FLUSH_CONSTANT_REGS at every vtlb seam.

The marks are valid to keep because the callee set at these seams -- the
registered MMIO handlers and vtlb_memRead/Write -- cannot write guest GPRs.
x86 relies on the same invariant more aggressively: its equivalent sites use
FLUSH_FULLVTLB (0x000) and flush nothing at all, values included. We keep the
value writeback because the softmem paths re-read guest GPRs from cpuRegs
memory post-flush and vtlb exception paths expect memory to be current; that
writeback is what the Tier-2G bisect (f4601964b) found load-bearing, and it
is preserved here. Interpreter seams must NOT set the modifier -- the
interpreter does write cpuRegs.GPR, so its marks go stale.

This makes several previously-dead paths live for the first time, since
recLQ/recSQ/recLQC2/recSQC2 and the post-flush GPR_IS_CONST1 folds in
recLoad/recStore never saw a live const mark before. _allocGPRtoNEONreg
already handles both directions (MODE_READ materializes a const as a 128-bit
memory load plus an Ins of the constant into lane 0; MODE_WRITE deletes the
mark), which the new ConstQuadStoreMaterializesAndAliasLoadKillsConst fixture
pins along with the rs==rt alias case.

Note the const-MMIO shortcut sites move from FLUSH_INTERPRETER to
FLUSH_VTLB|FLUSH_PC, which drops FLUSH_FREE_XMM and so lets the GE-15
retention keep FPREG/FPACC slots mapped in q10-q15 across the handler call.
That is safe here -- MMIO handlers do not RMW cpuRegs.fpr -- and makes the
shortcut consistent with the softmem seams. Dropping FLUSH_ALL_X86 changes
nothing: iFlushCall frees all caller-saved GPRs unconditionally.
FLUSH_FREE_VU0 is inert on arm64; only x86's iFlushCall consumes it.

ConstAddrMmioBurstStaysOnShortcut is red before this change (accesses 2..N
land in the faulting-PC set) and green after. Full suite 1423/1423.

Taken from https://github.com/yaps2/yaps2/pull/10 by pstef
(https://github.com/pstef).

Co-authored-by: pstef <3462925+pstef@users.noreply.github.com>
2026-07-24 18:34:15 -07:00
Brian Degenhardtandpstef aead47e805 tests/ee: pin resident self-loop byte-copy semantics
Two fixtures for the DBZ3 byte-copy loop at EE pc 0x002a9aa8: lbu/sb
through separate walking pointers with the counter compared against a
non-zero bound held in a register, so the loop exits on the sign-extended
ADDIU wrap rather than on zero.

A stepdiff run originally flagged this block (JIT a2=-1 vs interp
a2=0x10); that report turned out to be a sampling artifact of SL-1
residency -- one block-entry sample per resident run against interp's
per-op stream -- not a divergence. These pin the semantics the artifact
was mistaken for: trip count, copied bytes, and final pointer/counter
state. The overlapping variant (dst-src smaller than the length) pins the
strict per-iteration memory order, so any load hoisting or store deferral
across resident back-edge iterations changes the result and fails.

Both pass on the current tree; they are regression pins, not bug repros.

Taken from https://github.com/yaps2/yaps2/pull/10 by pstef
(https://github.com/pstef).

Co-authored-by: pstef <3462925+pstef@users.noreply.github.com>
2026-07-24 18:34:15 -07:00
Brian Degenhardt 9fa68bdfce eerunner: --rec-fallback opcode-group interpreter bisect switch
EE JIT divergence hunts start at "this game miscomputes something under the
EE recompiler" with no idea which emitter is at fault, and the existing
funnel (stepdiff/contmem) localizes to a frame and a RAM region — which
chaotic float amplification makes almost unreadable by end of frame.

Add a harness-only switch that routes whole EE opcode groups through
recCall(interpret) instead of their native emitter, everything else still
JIT. The interpreters are the known-good baseline, so the group that makes
the symptom disappear contains the bug, and each probe is one run instead of
a rebuild per hypothesis. Groups: fpu, cop2, mmi, multdiv, shift, arith,
loadstore, move, cop0, branch, plus all/none. cop2 narrows into cop2move /
cop2vu / cop2ls and then into qmfc2 / cfc2 / qmtc2 / ctc2, and those four
accept a `:<reg>` destination filter (`ctc2:0`) since their fs field names
32 registers with wildly different semantics.

Gated on PCSX2_RECOMPILER_TESTS so it never reaches a shipping build; the
non-hooks path compiles to `constexpr bool forcedInterp = false`. A forced
fallback also feeds delaySlotNeedsBranchBracket, matching the conservatism
already applied to genuine !opcode.recompile fallbacks, so the switch cannot
itself perturb delay-slot exception semantics.

Pairs with `--mkstate --renderer sw`, whose savestate carries a rendered
screenshot, to give a headless visual oracle for graphical bugs. Together
these took the Xenosaga zoom bug from "somewhere in the EE JIT" to the exact
instruction (CTC2 to vi00) in about ten runs.
2026-07-24 17:35:31 -07:00
Brian Degenhardt 74ef511d31 arm64: emit the VU0 interlock for CTC2 to vi00
vi00 is hardwired to zero, so `CTC2 rt, $vi00` writes nothing — which makes
it a free VU0 barrier, and games use it as exactly that. recCOP2_CTC2
returned on `fs == 0` before reaching cop2EmitConditionalSync, so the
barrier was compiled to nothing at all: the EE then ran ahead of an
in-flight micro program and consumed half-computed VU0 results.

Xenosaga Episode I (SLUS-20469) brackets its transform setup with
interlocked CTC2-to-vi00 and rendered massively zoomed in under the EE JIT
while EE-interp and the pre-transplant JIT were both correct.

Both references emit the sync ahead of any destination-based early return:
x86 recCTC2 calls COP2_Interlock(1) before its `if (!_Rd_) return`, and the
interpreter runs vu0Sync() + _vu0WaitMicro() before its `_Fs_ == 0` check.
Emit the interlock for vi00 (write side stays a no-op), and move the
MAC/TPC/VPU_STAT read-only returns after the sync for the same reason.

Regression test drives a pending micro program across the barrier and reads
the result with a following CFC2 — the COP2 analysis pass marks only the
first COP2 op after a kick as the sync point, so that CFC2 emits no sync of
its own and reads whatever the barrier left behind. Red before, green after.
2026-07-24 17:35:31 -07:00
Brian Degenhardt 821257346c FullscreenUI: controller-navigable first-time setup in Big Picture
The Qt SetupWizardDialog is mouse/keyboard-only and ran before Big Picture
started, so launching -bigpicture (or StartBigPictureMode) on a fresh datapath
hit an un-navigable modal. There is no gamepad->Qt bridge; controller input
only drives ImGui/FullscreenUI.

Skip the Qt wizard when starting into Big Picture and surface a
controller-navigable setup flow in FullscreenUI instead. A new
MainWindowType::Setup walks Welcome -> BIOS -> Game Directories ->
RetroAchievements -> Finished, reusing the existing BIOS and Achievements
settings pages and an extracted DrawSearchDirectoriesList() (factored out of
DrawGameListSettingsWindow). Welcome offers Set Up vs Skip; completion clears
UI/SetupWizardIncomplete and lands on the game list when directories exist.
Desktop first-run keeps the Qt wizard.

Two FullscreenUI correctness details: give each step its own window ID
(setup_content_{step}) so ImGui does not carry a stale NavId across steps and
leave a button unselectable by controller; and scope the settings lock to the
steps that draw settings pages, since the Welcome/Finished buttons call
CompleteSetupWizard() which re-acquires the non-recursive settings lock.
2026-07-24 15:30:32 -07:00
Brian Degenhardt 97eac53fa8 arm64: use the live x86 SetMaxValue constant in DOUBLE-mode FPU
x86 iFPUd.cpp SetMaxValue() branches on FPU_RESULT, which is #defined to 1,
so the only arm ever emitted is `xOR.PS(regd, s_const.pos[0])` with
pos[0] == 0x7fffffff. The arm64 DOUBLE-mode port transcribed the dead
else-arm instead and used 0x7f7fffff (+FLT_MAX, the single-precision
constant) at both SetMaxValue sites: SetMaxValueS, feeding RSQRT, and the
divide-by-zero result inside recDIVhelper1, feeding DIV. ToPS2FPU_Full's
overflow clamp in the same file already used 0x7fffffff, so the file was
internally inconsistent.

The two constants differ only in the exponent field, 0xff vs 0xfe. The EE
has no NaN/Inf encodings, so both are ordinary large finite floats there,
but guest softfloat routines do classify exp==0xff separately, which makes
the one-band difference game-visible.

NFS Carbon (SLUS-21493, eeClampMode:3) deadlocks on it. A disabled
sine-wobble axis leaves both table parameters +0.0, so the game divides
0.0/0.0 every time it builds the table and relies on the exp==0xff result
converting to <= 0 so the following blez skips the build. With 0x7f7fffff
the game's float->double->int helper saturates to INT_MAX instead, and the
game allocates and fills a 2^31-entry table, wiping guest RAM until a NULL
vtable dispatch lands the EE at PC 0 and the kernel halts; the SIF0
deadlock that surfaces is the aftermath, not the cause.

Two existing tests asserted the emitted (wrong) value; they are corrected
to the x86 result, and the 0.0/0.0 shape is added as a regression test.
2026-07-24 15:30:32 -07:00
Brian Degenhardt b8e7ad46ad eerunner: fix stepdiff self-loop skip and add entry-state probe
The self-loop phase-skip only consulted BlockBackwardBranchTarget on
ar.pc (the fall-through), so a self-loop whose divergence is observed
at its exit was never skipped and stopped the walk as a false positive
(this is what pinned Carbon's 0x174c30 byte-fill loop). Also check
ar.prev_pc, the offending self-loop itself.

The zoom report now dumps both streams' GPRs and FPRs at the offending
block head, so identical-entry false positives are visible directly in
the report instead of needing a hand-built harness.
2026-07-24 15:30:32 -07:00
Brian Degenhardt 365f69c641 tests: EE self-loop byte-fill store fixtures (Carbon 0x174c30 shape)
Six fixtures replicating the SB/ADDIU/BNE self-loop at 0x174c30 in NFS
Carbon (SLUS-21493), including the exact game entry state. All pass —
pinning that the block compiles correctly and that the stepdiff report
naming it was a sampling artifact of the tool, not a JIT bug.
2026-07-24 15:30:32 -07:00
Brian Degenhardt 9eb93d4dcd arm64: divert stale block-link sites to the dispatcher, not JITCompile
Remove() stamped a removed block's entry with B JITCompile, and Link()
routed not-yet-compiled targets there too. JITCompile unconditionally
recompiles from cpuRegs.pc — so when a block was removed and later
recompiled at a new address, any orphaned link site still branching to
the old entry re-entered recRecompile on an already-compiled block:
fnptr assert on Devel, and on Release a spurious in-place recompile
that superseded live code and stranded other orphaned callers in the
stale pre-SMC compile (the NFS Carbon SLUS-21493 post-FMV deadlock,
localized by twindiff against the pre-transplant build).

Every link tail in both recs already stores the target guest pc before
its branch, so the correct stale-site policy is re-DISPATCH, never
re-COMPILE: divert to DispatcherReg, which routes through the recLUT
(the single SMC-invalidation rewrite point) to the current code, or to
compilation via the LUT's own JITCompile slot when genuinely
uncompiled. Both AetherSX2's Remove() and the pre-transplant linker
enforce this same fallback-to-dispatcher policy; the recRecompile
fnptr assert becomes a true invariant again.

Zero hot-path cost: the dispatcher hop is paid only by a cold first
execution of an unresolved link (repatched direct by New()) or by a
stale site after SMC churn. Remove() keeps its signal-safety contract
(single atomic 4-byte B patch, no link-map access).

recompiler_tests: 1415/1415. Carbon twincompare free-runs 200 frames
where it previously aborted on the recRecompile assert.
2026-07-24 15:30:32 -07:00
Brian Degenhardt 2e9762d1d6 Rebrand user-facing PCSX2 references to ARMSX2; fix Help menu
Help menu (Linux desktop and everywhere):
- GitHub Repository pointed at a dead branch (/tree/macOS); now the repo root.
- Removed the PCSX2 Wiki and Documentation items (they linked pcsx2.net /
  wiki.pcsx2.net, impersonating upstream) and replaced them with a single
  ARMSX2 Website item pointing at armsx2.net.
- Removed Check for Updates (the auto-updater is disabled, so it only ever
  errored) and About Qt.
- About dialog body reworded to describe ARMSX2 (crediting PCSX2 as upstream).

Wrong-destination / impersonation fixes:
- PINE MsgVersion reply now identifies as "ARMSX2" (buffer sized accordingly).
- Bug-report links (GS unknown-video-mode / invalid-lod, EE COP2 warnings) now
  point at github.com/ARMSX2/ARMSX2/issues.
- "download a fresh copy" recovery messages (VMManager, SaveState, Windows
  updater, Win32 update-not-supported dialog) now point at armsx2.net.
- Auto-updater release/compare endpoints and staging-dir name de-PCSX2'd.

UI strings: setup wizard, cover downloader, and the Settings widgets (Qt) plus
their Big-Picture/Fullscreen ImGui twins now say ARMSX2. The "PCSX2Blue" theme
settings key is left unchanged (persisted / matched in code); only its display
name changes.

Interop identifiers deliberately left as-is: the PINE socket name (pcsx2.sock,
for PINE-client compatibility) and the RetroAchievements client name.
2026-07-23 19:48:13 -07:00
Brian Degenhardt d2bce662d4 PINE: marshal GS-stats sampling onto the CPU thread to avoid MTGS deadlock
The PINE server thread called MTGS::RunOnGSThread + WaitGS directly to sample
GS memory stats and device/driver info. The MTGS ring is single-producer:
m_WritePos is owned by the EE/CPU thread, so pushing a packet from the PINE
thread creates a second producer that races the EE thread's own ring writes,
desyncing the pending-packet count. That lost wakeup deadlocks the EE thread
(parked in WaitGS) against the GS thread (asleep in WaitForWork).

Marshal the sample onto the CPU thread first -- the legitimate ring producer --
and block until the GS-owned data has been gathered.
2026-07-23 19:46:52 -07:00
Brian Degenhardt 0b4d0bd03d sdl: fall back to a Wayland Vulkan surface when a compositor is present
The SDL frontend previously only ever reported WindowInfo::Type::VulkanDirect
and relied on VK_KHR_display to acquire the display. That fails when the
frontend is launched inside a Wayland session, where a compositor owns the
display (desktop, phone, or an embedded gamescope-style compositor).

BuildWindowInfo() now checks WAYLAND_DISPLAY: when set, it brings up an SDL3
SDL_WINDOW_VULKAN window on the wayland video driver and hands its wl_display /
wl_surface to the Vulkan backend as a Type::Wayland surface (the backend's
vkCreateWaylandSurfaceKHR path already existed). Any failure logs a warning and
falls through to the existing VK_KHR_display path, so bare kmsdrm devices are
unaffected. The whole block is gated on WAYLAND_API.

Enable WAYLAND_API in the clang-handheld preset so this path compiles. Pure
kmsdrm targets without the ECM / Wayland-Egl headers can still build
VulkanDirect-only via -DWAYLAND_API=OFF.
2026-07-23 08:45:47 -07:00
Brian Degenhardt 7bfe6a44a1 gamedb-overlay: let SLPM-74405 inherit InstantDMAHack from bin
The mobile GameDB base predated upstream PCSX2's addition of InstantDMAHack
("Stops hang when entering Sword Select screen") to Samurai: Complete Edition
and never resynced, so the Android/iOS copies carried EETimingHack alone. The
generated overlay faithfully reproduced that stale state, which the override
loader's clear-then-replace on gameFixes would re-inflict on mobile/ARM64-Linux.

The drop was never intentional (no commit in the Android line ever touched this
entry's fixes; the fix is an arch-neutral DMA-timing hang fix). Remove the
overlay entry so the mobile tiers inherit bin's full [EETimingHack,
InstantDMAHack].
2026-07-22 21:16:45 -07:00
Brian Degenhardt ea351894e8 ios: bundle resources from bin and converge on the runtime overlay
Point the iOS Xcode/CMake resource bundling at the canonical bin/resources tree
instead of a committed iOS copy, mirroring the desktop and Android builds. The
GLOBs now embed bin/resources fonts/shaders/sounds and the top-level GameDB /
Redump / controller data into the app bundle's Resources/, excluding the dx11
shaders iOS never compiles (it renders with Metal).

iOS also drops its bespoke build-time GameDB merge (tools/gamedb-convert.py plus
its GameIndex[original]/[override].yaml inputs) and instead bundles the canonical
mobile overlay bin/resources-overlay/armsx2_overrides.yaml, which the shared
GameDatabase override loader applies at runtime from EmuFolders::Resources — the
same path every other platform now uses. This converges iOS onto Android's
mobile GS/JIT tuning (its committed GameIndex was stale — older Tekken clamp,
missing the Ziemas and Crazy Frog fixes) and onto one override code path.

Delete the 41 committed iOS resource duplicates; patches.zip stays as the only
iOS-only extra. Needs a macOS/Xcode build to confirm the bundled Resources/.
2026-07-22 21:16:45 -07:00