Commit Graph
22653 Commits
Author SHA1 Message Date
Brian DegenhardtandClaude 94de4fd55c GV7-1d-ii-a: extract the front<->back channel from GSState
Move the record ring, wake semaphore, and both pool arenas/free rings into
GSBackQueue::Channel. Each GSState owns channel storage and works through a
m_chan pointer (defaulting to its own storage), so the upcoming two-object
pipelined split can aim a front parser object at the back object's channel
without touching any record or pool logic. DrainBackQueue keys on the
channel's consumer_running flag instead of the producer flag, making drains
work from either side; payload node-0 adoption becomes an explicit
AdoptTransferBuffer() run by the staging object. The destructor frees only
its own channel storage. No behavior change in any mode.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 13:42:51 -07:00
Brian DegenhardtandClaude 6d998af9ff GV7-1d: lockstep drain spins before sleeping
Live MQ65 lockstep run measured 30->6 fps (GS frame 32ms->111ms): the
per-record WaitForEmpty futex round-trip at thousands of records per
frame is almost all of it. Records usually execute in microseconds, so
WaitForEmptyWithSpin catches nearly every drain on the spin path.
Lockstep remains per-record synchronization — the bisect rung, not a
shipping mode; the throughput answer is pipelined mode.

Gates: gs_vertex_tests 21/21; mode-2 gsrunner hashes bit-identical to
GV-0 baselines, all 10 dumps, vk + sw.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 13:28:29 -07:00
Brian DegenhardtandClaude 1b5ea8f159 GV7-1d: the back thread — lockstep mode live
Spawn the GS back thread (WorkSema loop over the SPSC RecordRing) when
GSBackThreadMode >= Lockstep. Every Submit* seam now routes: queued
modes push the record into the ring (consumer dispatches by tag through
ExecRecordSlot and releases draw nodes after the tail); modes 0/1 keep
the direct/inline paths untouched. Lockstep drains after every push,
which is what makes executing against the shared single-object state
safe — true pipelining needs the front-object split, so Pipelined runs
lockstep until that lands.

VSYNC records are never queued: SubmitVsync drains and presents on the
MTGS thread, keeping the back thread off the GSDevice on present paths
entirely. Queued modes engage only for Vulkan HW or SW renderers (a GL
device is context-bound to the MTGS thread; SW never touches the device
off the vsync path) — anything else warns and falls back to inline
records. ExecVsyncRecord becomes a GSState virtual for the dispatch
switch; GSRenderer's implementation overrides it.

Drain seams added (no-ops in lockstep, load-bearing under pipelining):
Reset, SoftReset, InitReadFIFO, Read, ReadLocalMemoryUnsync, Freeze,
Defrost, and StopBackThread (drain + exit + join) at destruction.

Gates: gs_vertex_tests 21/21; gsrunner PNG hashes bit-identical to GV-0
baselines on all 10 dumps, vk + sw, modes 0, 1 AND 2; mode 3 verified
falling back to lockstep.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 13:05:11 -07:00
Brian DegenhardtandClaude 4f20fc6f4c GV7-1c: transfer payload pool — TRANSFER records own their staging bytes
In record modes, m_tr.buff aliases a pooled 4MB node (the ctor adopts
GSTransferBuffer's own allocation as node 0). TRANSFER records reference
slices of the current node; slices of one logical transfer share it —
front appends and consumer reads touch disjoint ranges, so that's safe
under pipelining. At the next transfer Init (TRXDIR) after any record
referenced the buffer, the front rotates to a fresh node and emits a
RELEASE_PAYLOAD record behind the slices — FIFO ordering guarantees
they were consumed by the time the release returns the node to the
pool. Readback Inits rotate too, since ReadImageX writes into the
staging buffer.

The whole-packet Write fast path stages through the pooled buffer under
record modes (GIF packet memory is transient — a queued consumer would
read freed data); mode 0 keeps today's zero-copy reference.

Pool: 8 nodes / 32MB cap, same free-ring + arena + backpressure shape
as the draw-node pool. Mode 0 is untouched.

Gates: gs_vertex_tests 21/21; gsrunner PNG hashes bit-identical to GV-0
baselines on all 10 dumps, vk + sw, mode 0 AND mode 1.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 12:56:35 -07:00
Brian DegenhardtandClaude d779cfcec4 GV7-1c: draw-node pool — DRAW records own their vertex/index arrays
On the record path, FlushPrim now hands the live heap arrays to a pool
node before building the DRAW record: the buffer structs are snapshotted
into the node and the heap arrays exchanged, so the parse slot
(m_vertex_buffers[i]) takes the node's recycled arrays as fresh buffers
and keeps its xy-ring/counter state untouched — every array-indexed
consumer (PushBuffer, FlushBuffers, CheckWriteOverlap) is unaffected.
The record references the node's structs, valid until the consumer
releases the node (inline modes: FlushPrim right after the executor
returns; the back thread takes over that release when it lands).

Pool: free-list SpscRing (back producer / front consumer) + front-owned
arena capped at 64 nodes = ring capacity, so Release can never fail and
Acquire past the cap becomes the pipelined backpressure wait. Node
arrays are allocated to the current buffer's maxcount and float
organically through the swaps afterwards.

Off path (mode 0) untouched — no pool, no record, direct tail.

Gates: gs_vertex_tests 21/21; gsrunner PNG hashes bit-identical to GV-0
baselines on all 10 dumps, vk + sw, mode 0 AND mode 1.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 12:51:57 -07:00
Brian DegenhardtandClaude 370fe6e29a GV7-1b: GSBackThreadMode config + record-off fast path
Add the GV-7 mode ladder as a restart-required GS option
(EmuCore/GS GSBackThreadMode: 0=off, 1=inline-records, 2=lockstep,
3=pipelined; default 0). Restart-required means the mode can never
change under a live GSState, so it's sampled once at construction.

Off now skips the DRAW record round-trip entirely: every field the
record carries is captured from live state and installed back over the
same live state, an identity — FlushPrim calls the executor tail
directly instead. ExecDrawRecord splits into the install block +
DrawRecordTail(draw_serial), which is the shared tail for both paths
(and closes the ~2 env memcpys/draw the GV7-0d inline path was paying;
GV7-3's bool-off profile verifies against the GV-6b baseline).

Mode >= 1 keeps the GV7-0 build+execute-inline shape (modes 2/3 fall
back to it until the thread lands). gsrunner grows -backthread <mode>
so the gate matrix can pin both rungs.

Gates: gs_vertex_tests 21/21; gsrunner PNG hashes bit-identical to
GV-0 baselines on all 10 dumps, vk + sw, in BOTH mode 0 and mode 1
(record path confirmed active via the new startup log line).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 12:45:24 -07:00
Brian DegenhardtandClaude e59ad346e0 GV7-1a: front->back SPSC record ring + unit suite
Add the GV-7 queue primitive to GSBackQueue.h: a single-producer/
single-consumer ring templated over slot type and power-of-two count,
using free-running u32 cursors with acquire/release ordering only (no
RMW, armv8.0-safe). Records are built directly in the ring slot via
BeginPush/CommitPush, so queued mode adds no intermediate copy on top
of the record build itself.

RecordSlot is the tagged variant sized/aligned for the largest record
(DRAW, 1632B); all record types are statically asserted trivially
copyable so slots recycle without destructor bookkeeping. RecordRing =
512 slots (~860KB).

New gs_backqueue_tests.cpp (in the gs_vertex_tests binary): FIFO +
capacity + backpressure edge, wraparound, tag round-trip through
RecordSlot, and a two-thread spin stress (1M values, 64-slot ring)
checking exact in-order delivery.

Nothing production-side consumes the ring yet — that lands with the
mode switch behind the GSBackThread config bool.

Gates: gs_vertex_tests 21/21; gsrunner PNG hashes bit-identical to
GV-0 baselines, all 10 dumps, vk + sw.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 12:39:09 -07:00
Brian DegenhardtandClaude 7e612f4750 GV7-0e: PCRTC_SYNC + VSYNC records with inline executors
PCRTCDisplays is not vsync-only state — the HW Draw() heuristics read it
per draw — so under the split it is duplicated front/back and refreshed
by a PCRTC_SYNC record carrying the whole digested GSPCRTCRegs plus the
pre-decrement scanmask counter. GSvsync now digests (unchanged), submits
the PCRTC record, flushes, then submits a VSYNC record carrying
field/registers_written/idle_frame; the executor runs the whole VSync()
body (Merge, present, capture). Record order reproduces today's
semantics: vsync-flushed draws see the fresh display state, mid-frame
draws the previous frame's. Merge's scanmask decrement stays back-side;
the front mirrors it at enqueue once the copies are distinct (GV7-1).

GSPCRTCRegs hoists to GSBackQueue.h as the record payload type; GSState
keeps an alias.

Gate: gs_vertex_tests 17/17; gsrunner PNG hashes bit-identical to the
GV-0 baselines on all dumps, Vulkan and SW.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 11:53:04 -07:00
Brian DegenhardtandClaude f7816e589c GV7-0d: GS draw seam — self-contained DRAW records + inline executor
FlushPrim splits at the audit's seam line. The front half captures the
carry-over window (pre-rounding, as today), updates the texture-flush
flag (front-computable, front-read), assigns the draw serial, and builds
a self-contained DrawRecord: the staged draw environment, the live-env/
m_v next-draw peek the HW look-ahead heuristics read, temp_draw_rect,
flush reason, channel-shuffle-finish and packed-UV flags, serial, and
the vertex/index buffer set. ExecDrawRecord installs the record and runs
the old tail — sprite-blit frame-rate detection, scissor update, vertex
trace, texel rounding, Draw(), perfmon — reading the draw serial from
the record. The buffer reset + carry-over rebuild stay front-side, after
the executor.

GSVertexBuff/GSIndexBuff hoist to GSBackQueue.h (VertexBuff/IndexBuff)
as the record payload types; GSState keeps aliases. The GV7-1 pool will
hand ownership of these across the thread boundary.

Gate: gs_vertex_tests 17/17; gsrunner PNG hashes bit-identical to the
GV-0 baselines on all dumps, Vulkan and SW.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 11:48:28 -07:00
Brian DegenhardtandClaude 33a329fdb7 GV7-0c: split GSClut::Write into decision state and palette load
The CLUT write decision chain (WriteTest / CanLoadCLUT / InvalidateRange
dirty tracking) is purely register/address-based; only the load itself
reads palette bytes from local memory. Split Write() at that seam:
WriteDecision updates m_write/m_CBP (front side), WriteLoad sets
m_read.dirty and dispatches the m_wc loader (back-executable).

ApplyTEX0 now routes through SubmitClutLoad, which updates the decision
state at submit time and builds a self-contained ClutLoadRecord executed
inline by ExecClutLoadRecord — the CLUTLOAD leg of the GV-7 record
stream.

Gate: gs_vertex_tests 17/17; gsrunner PNG hashes bit-identical to the
GV-0 baselines on all dumps, Vulkan and SW.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 11:36:56 -07:00
Brian DegenhardtandClaude 134c571f29 GV7-0b: GS transfer/move seam — self-contained records + inline executors
Introduce GSBackQueue.h record types for the GS front/back split (GV-7):
TransferRecord (one HOST->LOCAL slice: register snapshots, payload span,
partial-end fixup inputs, cursor init) and MoveRecord (LOCAL->LOCAL blit
registers). FlushWrite and the GSState::Write whole-packet fast path now
build a TransferRecord and hand it to ExecTransferRecord; the TRXDIR
local->local case builds a MoveRecord via SubmitMove and ExecMoveRecord
installs it and runs the unchanged virtual Move chain (HW hack -> TC
move -> software blit). Records are executed inline today; GV7-1 moves
execution to the back thread.

Back-owned effects move into the executors: the m_draw_transfers
upload-queue push (from Write first-packet time to first-slice execution
— order-equivalent because every consumer path passes through FlushWrite
first: TRXDIR handler, FlushDraw, Flush), s_last_transfer_draw_n
stamping, InvalidateVideoMem, the wi() local-memory write, and the
Swizzle perfmon stat (stat_len preserves the fast path's raw-packet
counting). The executor owns the write cursor across slices
(m_exec_tr_x/y), mirrored back into m_tr.x/y inline for savestate
coherence. The staged path invalidates via the live m_env.BITBLTBUF
while the fast path uses m_tr.m_blit — captured per-record in env_blit,
preserving both behaviors exactly.

Gate: gs_vertex_tests 17/17; gsrunner PNG hashes bit-identical to the
GV-0 baselines for all 10 dumps on both vk and sw renderers.

Seam classification: scratchpad/gv7-2026-07/SEAM-AUDIT.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 11:26:57 -07:00
Brian DegenhardtandClaude d52ea81dd4 GV-CLOSE: strip GS_VERTEX_CROSSCHECK campaign scaffolding
The dual-path crosscheck (parse, scalar cull, fused FindMinMax vs their
legacy kernels per vertex/prim/draw over live replays) did its job — it
caught the fan-class FindMinMax coverage bug that the property sweeps
could not see — and the campaign is measured and closed. The gtest
oracle suite (gs_vertex_tests, 17 tests) remains the standing gate.
Recover the crosscheck machinery from git history if a divergence hunt
ever needs it again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 10:17:10 -07:00
Brian DegenhardtandClaude 431b623763 GS: clean up compiler warnings in ROV heuristic and SW sync log
- GSRendererHW: drop the dead heuristic inputs (colormask/atst/afail/
  blend/date/ztst derivations) left from an earlier iteration of the ROV
  cost model — none feed multipass_color/depth. Recover from git if a
  fuller model returns. depth_to_color is only consumed by GL_PUSH,
  which compiles out of non-debug builds — mark [[maybe_unused]].
- GSRendererSW: s_n is u64; use PRIu64 in the LOG fprintf.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 10:10:52 -07:00
Brian DegenhardtandClaude 7601f25a52 GV-6b: divide per vertex at accumulate time — fused FMM covers varying Q
The MQ65 verification profile showed FindMinMax<TRIANGLE,iip,tme,!fst>
unchanged at 6.3% of the GS thread: GV-6's constant-Q gate declined the
dominant draw shape — perspective-projected meshes have per-vertex Q, so
STQ draws almost never have one constant Q — and we paid the accumulate
AND the legacy walk.

Replace the constant-Q fold with a single-vertex transcription of the
legacy STQ step at accumulate time: build {S/Q, T/Q, Q, Q}, blend-mask
NaN lanes out of the min/max chains, accumulate tnan. One 4-lane FDIV
per unique vertex vs the legacy walk's one per index-list pair (strips
reference vertices up to 3x). Per-lane the scalars go through the same
IEEE ops as the legacy pair-wise walk, and blend-masked min/max is
idempotent/assoc/comm, so the result is bit-exact with no decline cases
— FmmFinish is now unconditional and the monotonicity gates are gone.

Gates: gs_vertex_tests 17/17 (the four FMM sweeps now require bit-exact
match on ALL Q/ST configurations, including varying Q and NaN/inf);
GS_VERTEX_CROSSCHECK replay of all 10 dumps clean; sw+vk frame hashes
bit-identical to pre-campaign baselines.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 10:07:28 -07:00
Brian DegenhardtandClaude c16b88cb76 GV-6: fuse FindMinMax into vertex kick emission
GSVertexTraceFMM::FindMinMax re-walks the draw's index list at flush
(strip vertices up to 3x redundant) with a non-pipelined FDIV per vertex
pair — 6.6% of the GS thread on the MQ65 UYA profile. Accumulate the
min/max at index-emission time instead, where the vertex is
register/L1-hot, and consume the accumulator in GSVertexTrace::Update.

- Per-buffer FmmAcc (position/texture/color pairs) in GSVertexBuff,
  maintained by VertexKickDirect for triangle strips/lists. A watermark
  dedups already-folded vertices (past strip warmup only the register-
  resident new vertex accumulates); rewind/compaction sites clamp it so
  rewritten positions re-accumulate. Fan emissions poison the draw's
  fused state — the fan head doesn't fit the watermark model and
  FlushPrim can rebuild fan indices (caught by GS_VERTEX_CROSSCHECK on
  the UYA dump corpus).
- FmmFinish reproduces the legacy tail bit-exactly or declines: STQ
  requires one constant, normal, nonzero Q (min(s/q) == min(s)/q by
  monotone IEEE division; negative Q swaps; FLT_MAX sentinels folded at
  quotient level) and no inf/NaN S/T — legacy masks NaN quotients per
  lane and reports vt.nan, which a min/max summary can't reproduce.
  Declined draws run the legacy FindMinMax unchanged.
- aarch64-only: NaN detection relies on FMIN/FMAX propagation (sticky in
  the raw accumulator); SSE min/max can drop a NaN again. x86 keeps the
  legacy walk everywhere.
- TME/FST/IIP are stable across one draw's emissions (TestDrawChanged
  flushes or buffer-switches on any draw-affecting PRIM change), so
  kick-time PRIM flags select the accumulation policy.

Gates: gs_vertex_tests +4 property sweeps (600k draws vs a transcription
of the legacy walk: benign must fuse and match bit-exactly, special
Q/ST must decline or match); GS_VERTEX_CROSSCHECK replay of all 10 local
dumps clean; sw+vk frame hashes bit-identical to pre-campaign baselines;
recompiler_tests 1359/1359.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 09:49:24 -07:00
Brian DegenhardtandClaude f346103ef7 GV-3b: only refresh the cull mirror when the cull rect actually changes
RefreshKickMirror ran unconditionally from UpdateScissor, which fires on
every context switch — measured at 1.87% of the MQ65 GS thread on UYA.
Cache the cull rect the bounds were derived from (poison-initialized)
and skip the bounds re-derive + mirror refresh when it is unchanged;
context switches with an identical scissor become a single vector
compare. The buffer-reactivation refresh stays unconditional (copied
entries carry outcodes from the source buffer's bounds). Entries are
only ever read by the prim class that wrote them (ApplyPRIM resets the
strip window), so skipping on class-only changes is safe.

Gates: gs_vertex_tests 13/13; gsrunner frame hashes bit-identical for
all 15 dumps on sw and vulkan; GS_VERTEX_CROSSCHECK replay clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 09:12:14 -07:00
Brian DegenhardtandClaude 9c7921a58f GV-4: accumulate the draw rect across fused batches
Every accepted prim updated temp_draw_rect through memory: load, union,
scissor clamp (with its own scissor.in load), store. Accumulate the
per-prim rects in the batch cursor instead and fold them into
temp_draw_rect with a single union + clamp at each cursor seam.

Exact by two properties: rintersect is monotone and idempotent, so one
clamp over the union equals the legacy per-prim clamp-then-union chain;
and the draw's first prim (which replaces temp_draw_rect rather than
unioning) can only be the first prim accumulated after a seam, because
the index buffer only empties behind flush seams. All temp_draw_rect
readers (CheckFlushes/SetDrawBuffDirty, Flush and autoflush analysis,
CheckOverlapVertsSlow, the transfer paths) run behind cursor seams or
outside vertex batches, so the deferral is unobservable.

Gates: gs_vertex_tests 13/13; gsrunner frame hashes bit-identical to
the pre-campaign baseline for all 15 dumps on sw and vulkan;
GS_VERTEX_CROSSCHECK replay clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 08:19:14 -07:00
Brian DegenhardtandClaude Fable 5 321c9dcb9b GV-3: scalar-outcode accept/cull for the GS vertex kick
The per-prim accept/cull decision was the largest slab of the fused
packed handlers (~30% on the MQ65 annotate): a NEON bbox build whose
verdict crossed to scalar through 3x umaxv + 2x uminp + 5x fmov
serialized into a ccmp chain - all exposed latency on in-order cores.

Replace it, for the hot shapes (point/line always; triangle strips and
lists and sprites at native res without AA1 expansion), with an exact
scalar reformulation over per-vertex precomputed metadata:

- Scissor reject: the bbox is the min/max of the vertices, so "bbox
  outside an edge" == "every vertex outside that edge". For
  triangle/sprite at native res the ceil16/floor16 interior rounding
  and the cull rect's +/-8 fold into pixel-band bounds (band(v) =
  (v-1)>>4 vs (cull.x+14)>>4 etc.); point/line compare raw 12.4 coords
  against cull. The per-prim test is an AND of 4-bit outcodes.
- Interior-empty: (v+15)>>4 == ((v-1)>>4)+1 identically, so
  ceil16(min) > floor16strict(max) <=> all vertices share one band on
  that axis - a pure equality test (EOR/TST) on packed bands.
- Degenerate triangle: the legacy 128-bit eq on {x,y,x,y} window
  entries is xy equality - one u64 compare on the packed position.

Each kick appends a CullMirrorEntry (packed window xy + 28-bit bands +
outcode, one 16-byte slot) to a scalar mirror of the xy ring inside
GSVertexBuff, computed from the raw vertex XY on the scalar side where
it dual-issues against the NEON parse. The mirror is maintained
wherever the xy ring is written (kick, draw-buffer compaction,
PushBuffer copy, buffer-reactivation copy, FlushPrim fan rebuild), and
outcodes are re-derived from the stored positions on every scissor /
context / draw-buffer-env change (RefreshKickMirror; bands and
positions are bounds-independent). Bands are 28-bit so any s32 window
coord packs exactly - games that write junk in XYOFFSET pad bits (the
full 32-bit lane is subtracted, matching the NEON ring) cannot alias.

Rejected prims now never touch NEON; accepted prims compute the bbox
via the factored ComputeCullBBox (bit-identical to the legacy path)
feeding the draw_rect update. Fans, AA1, upscale, and the staged
piecemeal path keep the legacy CullTest - its extraction tower now
sits only on that fallback branch.

Gates: gs_vertex_tests 13/13 including 3M-case scalar-vs-legacy
property sweeps over GS-shaped scissors with band/edge-snapped coords;
GS_VERTEX_CROSSCHECK replay of all 15 dumps asserts scalar == legacy
per prim, clean; gsrunner frame hashes bit-identical to the
pre-campaign baseline on sw and vulkan; recompiler_tests 1359/1359.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:12:53 -07:00
Brian Degenhardt bc33ef1d09 arm64: attribute yaps2 authorship in SPDX copyright headers
Add "yaps2 Dev Team" copyright to the files we authored. Net-new files
(all 42 pcsx2/arm64/ codegen/ProgCache/persist sources, the recompiler
test suite + harness, and the vurunner/eerunner tools) never existed
upstream, so they carry yaps2 sole credit. RecStubs.cpp predates the
fork and was heavily extended, so it keeps PCSX2 credit and adds yaps2.

The five pre-existing arm64 files we only lightly touched (AsmHelpers,
Vif_Dynarec, Vif_UnpackNEON) stay PCSX2-only. GPL-3.0+ license lines are
unchanged throughout; this is authorship attribution only.
2026-07-19 07:37:15 -07:00
Brian DegenhardtandClaude Fable 5 c25e1a58cc GV-2: GS vertex kick: register-cache buffer state across fused batches
VertexKickDirect round-tripped every hot buffer field through memory per
vertex: m_vertex/m_index pointer loads plus head/tail/next/xy_tail/itail
loads and stores each kick, all loop-carried through store-forwarding on
in-order cores. The MQ65 annotate put this bookkeeping at ~15% of the
handler (m_vertex reloaded 7x, m_index 4x per vertex).

Introduce VertexKickCursor: the fused packed handlers load
{vb,ib,vbuff,ibuff,head,tail,next,xy_tail,maxcount,itail} into locals
once per batch and pass the cursor through VertexKickDirect, so the
fields live in registers across the whole GIF batch. The cursor is
stored back before - and reloaded after - every callee that can flush,
grow or switch draw buffers (CheckOverlapVertsSlow + Flush,
HandleAutoFlush, GrowVertexBuffer, Flush(VERTEXCOUNT)); GrowVertexBuffer
in particular reads tail/itail for its preserved-copy sizes. The
env-backup block (memcpys + SetDrawBufferEnv) touches no buffer state
and rides through cursor-resident. The staged VertexKick wrapper keeps
piecemeal handlers at their previous load-once/store-once shape.

Also hoist the depth-clamp decision out of the per-vertex path:
GetDepthClampMode() resolves config + renderer kind + ZBUF bpp once per
batch (all invariant across a fused batch) and ApplyDepthClampMode
applies the resolved mode, replacing 3 GSConfig loads and a
GSIsHardwareRenderer() call per vertex with one register compare - which
also removes the only warm-path call from the loop CFG.

Verified: the built <4u,false> handler loads the cursor once at entry
and after the two flush seams only; stores appear only on the overlap
slow path; depth-clamp is two cmp/branch on the disabled default.

Gates: gs_vertex_tests 9/9; gsrunner frame hashes bit-identical to the
pre-campaign baseline for all 15 dumps on sw and vulkan;
GS_VERTEX_CROSSCHECK replay of all dumps clean; recompiler_tests
1359/1359.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:35:20 -07:00
Brian DegenhardtandClaude Fable 5 64ff1d5a62 GV-1b: TBL-based packed vertex parse on aarch64
Replace the SSE-translated parse chain with byte-permute kernels: one
vqtbl2q_u8 builds m[0] (S/T/RGBA/Q gathered from the STQ+RGBAQ qwords —
the legacy RGBA pack alone was ~11 NEON ops), and for XYZF2 a shared USHR #4
plus one vqtbl2q_u8 over {r2, r2>>4} builds m[1], with out-of-range TBL
indices providing the 24-bit Z and 8-bit F masks for free. XYZ2 takes a
single-register TBL for the low half and inserts {UV, FOG} as one 64-bit
lane. The Q==+0.0 -> FLT_MIN rewrite folds to CMEQ+AND+ORR against a
lane-3-only constant.

Handlers call the new _Fast dispatchers: aarch64 takes the TBL kernels
(GS_VERTEX_CROSSCHECK builds run the legacy kernels alongside and
pxAssertRel bit-equality per vertex); x86 keeps the legacy path unchanged.

Gates: gs_vertex_tests 9/9 including 2M-case NEON-vs-scalar-model sweeps;
gsrunner frame hashes bit-identical to the GV-0 baseline (15 dumps, sw +
vulkan); crosscheck build replayed all 15 dumps with zero divergence
assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:02:23 -07:00
Brian DegenhardtandClaude Fable 5 44f5e503dc GV-1a: kick parsed vertices straight from registers, write m_v once per batch
Split VertexKick into a staged-m_v wrapper (piecemeal reg handlers, autoflush)
and VertexKickDirect, which stores the incoming vertex to the buffer from the
values still in registers. The fused packed STQRGBAXYZF2/STQRGBAXYZ2 handlers
(auto_flush=false instantiations) now parse into locals, kick directly, and
write m_v/m_q once at batch exit — removing two 16-byte staging stores, the
store-forwarded reload pair, and the loop-carried UV/FOG reload per vertex
(packed XYZF2/XYZ2 never write UV/FOG, so they are loop-invariant).

Semantics preserved exactly: the depth-clamp hack is factored into
ApplyDepthClamp and applied by both entry points (hoisting it ahead of the
overlap/autoflush checks is neutral — neither reads XYZ.Z), and the
draw-buffering overlap slow path syncs m_v before running since it reads the
incoming vertex's XY. Autoflush instantiations keep the staged path
(HandleAutoFlush reads m_v).

Gates: gs_vertex_tests 7/7; gsrunner frame hashes bit-identical to the GV-0
baseline across all 15 dumps on both sw and vulkan renderers;
recompiler_tests 1359/1359.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 06:57:25 -07:00
Brian DegenhardtandClaude Fable 5 92c3d5b247 GV-0: factor GS vertex parse + cull kernels, add oracle test suite
Extract the fused packed-vertex parse (STQRGBAXYZF2/STQRGBAXYZ2) and the
per-prim accept/cull decision out of GSState.cpp into pure free functions
(GS/GSVertexKick.h, namespace GSVertexKernels). No behavior change: gsrunner
frame hashes over all 15 local dumps are bit-identical on both the software
and Vulkan renderers.

New gtest target gs_vertex_tests pins the kernels against independent scalar
models of the GIF/GS semantics (plain integer C, no GSVector) over directed
edges (Q==+0.0 vs -0.0, 16-subtexel boundaries, duplicate vertices) plus
3.5M-case randomized sweeps per prim class. Optimized kernel implementations
in the GV campaign must pass the same suite bit-for-bit.

Also adds the GS_VERTEX_CROSSCHECK CMake option (default OFF) that later GV
items use to run legacy+new kernels side by side during dump replays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 06:42:16 -07:00
Brian Degenhardt 4ca00c6088 EE counters: clamp T_COUNT reads until the boundary interrupt is delivered
NFL 2K5 (SLUS-20919) hangs at the boot logo in a 64-bit divide-by-repeated-
subtraction with a huge unsigned dividend. Its 64-bit clock is an overflow-
ISR-maintained wrap accumulator plus a live T0_COUNT read (bus/16, OVFE),
reconciled lock-free with double reads — airtight on hardware, where the
count wrap and the overflow interrupt are the same edge and the ISR preempts
before any later read.

Under the JIT the guest can observe the wrap while the ISR's effects are
still pending, in two phases: (1) the count (derived from the live
cpuRegs.cycle) crosses the boundary before the scheduled rcntUpdate event
runs; (2) rcntUpdate has wrapped the count and raised the INTC, but the
exception waits for the next event test — which our static-linked /
short-block tails defer past the reader's entire load sequence (traced live:
the wrap event fires at the reader's own block-entry event test, and
delivery lands at its jr-ra exit, 30 cycles too late). Either way the game
reads stale-accumulator + wrapped-count, time goes backwards one wrap
period, and the divide runs ~2^48 iterations.

Clamp the read to just-before-the-boundary until the interrupt has actually
been delivered. The deliverability guard (INTC pending & unmasked & Status
EIE/IE, no EXL/ERL) makes this exact: inside the handler or with the source
masked (e.g. the game's DisableIntc reader, which reconciles the raw wrap
itself) the wrapped count stays observable, as on hardware.

Pinned by EeTimerCountReadRace.* in recompiler_tests. Verified live: cold
fastboot reaches attract; previously parked at the divide loop within ~20s.
2026-07-19 00:20:01 -07:00
Brian Degenhardt c4a934e9b9 Merge remote-tracking branch 'yaps2/main' 2026-07-18 19:33:55 -07:00