Aspect ratios: added 20:9, 19.5:9 and a user-entered Custom ratio
(GSOptions::CustomAspectRatio, clamped 0.5..5.0). All APPENDED, never inserted —
these values are persisted as raw ints in the ini and in the Android prefs, so
slotting one in mid-enum would silently repoint every saved config at a different
ratio. Also filled in the two ultrawide cases RequestDisplaySize was missing.
Interlace/presentation: ported sashkinbro's EmuCoreX 30799e4. SelectGSInterlaceMode
centralises the mode choice and keeps shader_mode -1 for automatic full-frame output
(a deinterlace pass must not run over progressive output during a video-mode
transition); our formula already agreed, so this is centralisation plus
static_asserts rather than a behaviour change. ShouldSkipAndroidBlankFrame is new
behaviour: Vulkan now suppresses only the startup blank, so a mid-game fade reaches
the normal present path and its recorded command buffer is submitted.
Vulkan: declare the attachment feedback loops on the PIPELINE, not just on the image
layout and render pass. We put attachments into FEEDBACK_LOOP_OPTIMAL without ever
setting VK_PIPELINE_CREATE_{COLOR,DEPTH_STENCIL}_ATTACHMENT_FEEDBACK_LOOP_BIT_EXT,
which the spec requires — undefined behaviour rather than a missed optimisation, and
strict mobile drivers are where undefined shows up as stale attachment reads.
The note-highway render target is never sampled back, so the GPU->CPU download is
pure cost on a tiler. Covers GH2 (SLES-54442, SLUS-21447 — the latter is also the
serial GH2 Deluxe ships under) and GH3 (SLES-54962, SLES-54974, SLKA-25363,
SLKA-25414, SLUS-21672).
gsHWFixes is a clear-then-replace map in this overlay, so each GH3 entry restates
every upstream key. Dropping one would have silently undone the crowd-texture,
bloom and post-processing fixes those entries already carry.
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.
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.
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.
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.
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.
Dragon Quest VIII PAL (SLES-53974) hung forever on "Now checking memory
cards (PS2)": full speed, frames still presenting, EE around ten percent
busy. NTSC-U was fine on identical settings.
A constant-address load in the EE counter window forces an event test so
the guest reads an up-to-date COUNT, and that ends the block. When such a
load is the delay slot of a forward conditional, superblock continuation
had already claimed the branch and registered a cold side exit for the
taken arm, so the mainline then terminated underneath a site that expects
to keep going. The design audited early truncation only for a later
instruction, and the scanner's delay-slot guard only refuses branch-class
ops, so a counter read passed it. PAL and NTSC-U differ only in where the
code sits, which is why one paired them and the other never did.
Refuse the continuation site for that shape, at both entry points. The
guard lives in the branch handler rather than the scanner because the
address is only known once constant propagation has run.
Audited every other way a delay slot could end a block: the two load
paths share the predicate this guard mirrors, syscall and break are
already refused as branch-class, and the interpreter fallback routes a
branch in a delay slot down the path that does not end the block.
Also adds continuation-site introspection so a test can ask a compiled
block which sites it kept. It records what emission committed to, not
what the scanner proposed -- the scanner proposes this site either way,
so only the emission-side record separates a formed superblock from a
refused one. Block size cannot: with the guard the branch ends the block,
without it the delay slot truncates it, and the compiled size is the same.
Test-build only.
Reported by MaestroLiendreOP.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Rounds 1 and 2 established that an uncached load sees stale RAM while
a dirty line exists; the store direction was not tested, and it is the
one that loses data. To a clean resident line, RAM takes the store and
a later cached read still returns the old value. To a dirty one, the
write-back overwrites it. And to a different word of a dirty line
- a word that never went near the cache - it is lost just the same,
because the write-back is 64 bytes wide and cannot know. That last case
is the one worth having: a game poking one word through UNCACHED_SEG
loses the poke if a neighbouring word happens to be dirty.
PCSX2 gets all three right and for the right reasons, so they are pinned
as agreement rather than recorded as divergences.
Filling an instruction-cache set means executing from it, so the probe
uses five ordinary compiled C functions at aligned(0x2000): 8 KB apart
leaves vaddr[12:6] alone, which given round 2's measured index width
puts all five in one set, and each is padded past four lines so four
independent sets are sampled at once. No hand-assembled bytes are
executed and the pre-flight decodes the five addresses out of the linked
ELF.
The rule is the D-cache's, way = LRF0 ^ LRF1 with the filled way's LRF
toggling, and it is checked by simulation rather than by eye: for each
of the twenty (depth, line) observations the rule is run forward from
all four possible initial LRF states and exactly one reproduces the
occupants and both LRF bits. Twenty rows, twenty unique fits. The
initial state is solved for rather than assumed because each round
starts from whatever the previous record's file I/O left behind. Two
side results:
FlushCache(INVALIDATE_ICACHE) leaves an invalidated line naming its old
address and keeps its LRF, the instruction-side counterpart of round 1's
DXIN finding; and PCSX2's D-cache driven from a reset model produces the
same LRF bits the console's instruction cache did.
cache 0x07 is IXIN - index-addressed, way-selected by bit 0, clears V,
keeps the tag address. Both earlier rounds only inferred the op number.