recLQC2/recSQC2 still paid iFlushCall(FLUSH_CONSTANT_REGS) on the
fastmem path — the legacy q0-detach shape — which frees every
caller-saved GPR/NEON allocator entry mid-block. UYA's hottest COP2
staging block (0x3F7690) pays it four times in a row. Rework both to
the GE-14 recLQ/recSQ shape: address before the const flush (keeps
the const-Rs fold), _flushConstRegs(true) only, and the quad staged
through RQSCRATCH (q30, never allocator-tracked) so no q0 detach is
needed. The softmem/faulting-PC fallback keeps the legacy full-flush
+ q0 shape.
VF memory is current at both sites without any flush: LQC2/SQC2 are
VF-cache-classifier-false, so recompileNextInstruction flushed the
COP2 VF compile cache before the emitter runs, and VF regs are never
EE-allocator-tracked. The fault path is the proven GE-07 live-mask
thunk (handles non-q0 data registers, saves live allocator entries).
Measured (M2 census, UYA 6000f, vs the S4-2 capture): static EE
bytes -0.16%, 146 blocks shrink / zero grow, EE_003F7690 -104B;
exec-weighted ldr_gpr_unpinned 5.04% -> 4.13% (the reload churn the
seam caused downstream), neon q ld/st -0.3pt combined, cop2_vf flat
by design. A compile-time VF-cache event trace + policy resimulation
sized the ledger's original "VF residency across macro chains" idea
at ~0.07% of cycles ceiling (flagship block: zero) — the seam, not
the residency policy, was the prize.
Tests: three new LQC2/SQC2 residency contracts (dirty caller-saved
scalars + const-folded base, macro-result-then-SQC2 ordering, dirty
resident MMI quad across an LQC2/SQC2 pair). Gates: 1390 tests, 8k
fuzz seeds, vucorpus bins catalog-exact, UYA stepdiff signature
identical, SotC stepdiff baseline-identical (its .01 signature
drifted since the S4-2 session but pre/post agree byte-for-byte at
block 0x006f7168 — pre-existing class, still uncharacterized).
Co-Authored-By: Claude <noreply@anthropic.com>
The conditional VU0-sync envelope (VPU_STAT gate, cycle-delta
flush/reload, lazy-pin flush/reload, retained-entry reloads, the sync
C calls) was re-emitted inline at every analysis-marked COP2 site —
15-25 instructions each, the dominant per-site byte carrier in
COP2-dense hot blocks. Emit it once per recResetEE instead, as five
shared stubs (one per sync/finish callee combination), and shrink the
per-site sequence to Add-cycles + BL. AetherSX2 4248 ships the same
shape (mVUmacroEmitCOP2_0/1 shared-stub family).
The stub checks VPU_STAT and returns immediately when VU0 is idle. On
the taken path it raw-preserves LR plus the six caller-saved allocator
pool registers on the stack, which replaces the old writeback-keep +
per-site reload protocol for retained GPR/FPRC entries entirely —
sound because the sync callees never read or write EE GPR memory or
fprc, the same invariant the old retain seam already relied on.
_reloadArm64GPR loses its only caller and is removed.
UYA M2 census vs the S4-1 baseline (same scene, 6000 frames): EE
emitted bytes -4.67% (1648 KB -> 1571 KB), 813 blocks shrink, none
grow; the flagship COP2 physics blocks EE_003F7690 / EE_003FA2D8 drop
20% each. Pin-seam reload/flush instruction counts fall 74%/54%; the
taken-sync execution moves to the shared stubs (dispatcher sample
share 0.06% -> 2.42%), net M2 sample share flat — the win is icache
footprint, to be scored on SD865.
Tests: three new taken-sync residency contracts (dirty caller-saved
scalars, dirty tier-2 lazy pins, and the interlocked .I forms — new
encoders — whose exact-sync+wait/finish stubs previously had no test
coverage), green pre- and post-change. Full battery: 1387 tests, 8k
fuzz seeds, vucorpus bins at catalog, UYA stepdiff bit-identical,
SotC stepdiff signature-identical to baseline (the 0x0010ef38 data
divergence reproduces byte-for-byte on pre-S4-2 emitters — pre-existing,
tracked separately).
Co-Authored-By: Claude <noreply@anthropic.com>
Faithful straight-line replay of the Naughty Dog camera kernel at guest
0x0061b44c (SCUS-97330): quaternion -> basis via VOPMULA/VOPMSUB cross
products, +1.0 diagonal via VADDw broadcast, per-axis scale via VMULx/y/z,
then the 4x4 transform via the VMULAx/VMADDAy/VMADDAz/VMADDw ACC chain,
stored with SQC2. Encodings verified bit-exact against the game words
(e.g. vmulax.xyzw ACC,vf07,vf02x = 0x4be239bc). Adds local encoders for
the broadcast VADD/VSUB family (upper funct 0x00-0x07) and VMADDAz.
This is the kernel that floods per-object matrices with +fMax in the
Jak 3 artifact repro; the test pins that the emission chain itself is
bit-identical to the interpreter for finite inputs (it is — the live
corruption enters through the kernel's scratchpad quaternion input, not
the codegen), and guards the whole broadcast/OPMULA/ACC-chain shape
against future regressions.
Co-Authored-By: Claude <noreply@anthropic.com>
recCOP2_QMFC2/QMTC2/CFC2/CTC2 opened with a compile-time-unconditional
iFlushCall(FLUSH_EVERYTHING) — every allocator entry and NEON quad evicted
at every transfer, even with VU0 idle. Route them through the register
allocator instead (x86 recQMFC2/recQMTC2 shape, AetherSX2 4248 parity):
- QMFC2: dest claims a NEON quad MODE_WRITE when rt is used later (stays
q-resident for following MMI/QMTC2 consumers); dead dests store straight
to the canonical image.
- QMTC2: source policy mirrors x86 — force a quad fill only for dirty
const / dirty scalar rt; reuse a resident quad for free; a clean miss
reads memory + merges the lazy pin without claiming a slot.
- CFC2: general path rides the coherent dest helpers; the REG_R partial
UL[0] write flushes rt residency with writeback first (UL[1]/UD[1]
survive; interp semantics kept — x86's 64-bit zero-extend divergence
deliberately not copied).
- CTC2: no flush needed at all — the body reads rt via _eeMoveGPRtoR
(const/scalar/quad/pin aware) and writes only VU0 state.
The analysis-gated sync seam inside cop2EmitConditionalSync (SL-2 retain
shape + runtime VPU_STAT Tbz) is untouched and remains the only flush
point in a transfer.
New hazard suite ee_vu0_cop2_transfer_residency_tests.cpp (19 tests) pins
the interleave contracts: MMI-quad/scalar/pin/const sources into QMTC2/
CTC2, QMFC2/CFC2 dests consumed by MMI/scalar/pin readers, REG_R partial-
write preservation, and residency riding the mid-block VU0 sync seam
(pending-micro VCALLMS recipe). The suite is green on the old emitters too.
Gates: recompiler_tests 1384 green; 8k-seed EE fuzz soak; UYA .02 + SotC
.02 stepdiff signatures identical to baseline (benign timer classes only);
vucorpus rep sweep bins match the documented catalog (the GTA-SA crash
class is EE-independent). M2 census: static EE bytes +0.04% — UYA hot
blocks are transfer-light, so the predicted icache relief lives in the
sync-seam size (S4-2) and VF residency (S4-3); exec-weighted neon_gpr
q-churn −0.4pt.
Co-Authored-By: Claude <noreply@anthropic.com>
Three stepdiff triage upgrades built during the Jak 3 artifact hunt:
- EERUNNER_VU0FP=1 mixes VU0 macro-visible state (VF00-31, ACC, the
STATUS/MAC/CLIP flag VIs) into the divtrace fingerprint and captures it
in FullSnap/DiffFullSnaps, so the zoom breaks at the EE block whose COP2
macro emission first produced divergent VU0 state instead of only when
it later lands in a GPR or memory (the default fingerprint surfaced the
Jak 3 fMax vertex flood at an unrelated memcpy). VF is memory-resident
at every block boundary per the COP2 VF-cache seam policy, so the JIT
block-prologue sample site reads consistent state. Opt-in: the benign
filters were tuned without VU0 fields, and mVU-vs-interp has by-design
NaN/clamp corners.
- EERUNNER_CENSUS=1 stops the zoom hard-stopping at the first non-benign
divergence: each data/control site is reported compactly, ResyncAfter
walks past TRANSIENT ones (DMA-phase staging buffers, poll-phase
control-flow splits that reconverge), and the walk stops at the first
PERSISTENT site. Persistent control-flow sites also dump the JIT-side
guest regs at the split entry (busy-poll operands), the polled word when
it resolves to RAM, and both sides' frame-end SPR channel registers.
- The stepdiff candidate-real branch now re-runs the frame once per mode
from the same checkpoint and byte-diffs EE RAM+scratch (ReportMemDiff,
max_show now a parameter): with a clean interp control every differing
byte is a real JIT store difference, at frame-local granularity.
Co-Authored-By: Claude <noreply@anthropic.com>
recMAX_S_xmm/recMIN_S_xmm emitted bare Fmaxnm/Fminnm with no operand
clamp. x86 routes MAX/MIN through recCommutativeOp(op>=2), whose gate
`CHECK_FPU_EXTRA_OVERFLOW || (op>=2)` always fires, so it fpuFloat2-clamps
both operands (sign-preserving inf/NaN -> ±fMax) whenever CHECK_FPU_OVERFLOW
— eeClampMode >= 1, a strictly lower threshold than the arithmetic ops'
>= 2. AetherSX2's shipped arm64 rec gates the same MAX/MIN clamp on
fpuOverflow (options bit 8) vs ADD/SUB's bit 8+9 (verified against the 3606
build disasm). Without the clamp a raw Inf/NaN operand (via MOV.S/LWC1/MTC1)
survives the NaN-eating Fmaxnm/Fminnm as the wrong finite value — the True
Crime: New York City rainbow (BlueTongue engine, SLUS-21106, eeClampMode:2),
a min(max(uv,0),size) UV-clamp idiom producing a corrupt palette index.
Add fpuClampMinMaxOperand (mirror of fpuClampInput, gated CHECK_FPU_OVERFLOW
instead of CHECK_FPU_EXTRA_OVERFLOW) and apply it to both operands.
Pinned by EeRecFpu.Max/MinSClamps* (mode-2 Inf/NaN cases + a mode-1 gate
test + a mode-0 no-clamp lower bound). Interp fp_max/fp_min run on raw bits
with no clamp, so the correct JIT diverges from interp here — the tests use
RunJitNoDiff/GetFprBitsJit (x86 JIT is the FPU-clamp oracle, not interp).
Co-Authored-By: Claude <noreply@anthropic.com>
Adds a 'GS Back Thread' dropdown to Graphics > Advanced in both frontends
(Disabled / Inline Records / Lockstep / Pipelined, default Disabled). The
setting is in RestartOptionsAreEqual, so changing it mid-session performs a
full GS reopen and takes effect immediately. Placed at sparse form-layout
row 7 so the existing removeRow() position bookkeeping in
GraphicsSettingsWidget is untouched.
Co-Authored-By: Claude <noreply@anthropic.com>
A spawned thread inherits its spawner's affinity, and the back thread is
spawned by the MTGS thread — which EnableThreadPinning pins to a single
core. Whenever the back thread is (re)spawned while pinning is active
(any GSreopen: renderer switch, restart-class settings apply), it would
inherit that one-core mask and front and back would time-slice a single
core, silently re-serializing the split. Clear to all cores at thread
entry; explicit pinning policy for this thread stays a VMManager concern
(future work: a fourth pin slot on 4-big-core targets).
Fresh boots were unaffected (SetEmuThreadAffinities runs after GSopen),
which is why live sessions looked fine.
Gates: gs_vertex_tests 21/21; gsrunner hashes identical, modes 0/3,
vk and sw.
Co-Authored-By: Claude <noreply@anthropic.com>
Sync-point audit fixes for pipelined mode: GSUpdateConfig's non-reopen
branches, SaveSnapshotToMemory, and capture begin/end all touch renderer
or GSDevice state from the MTGS thread while the back thread may be
mid-draw on the same device. Each now drains queued records first (the
front only parses on the MTGS thread, so nothing new queues during the
operation). DrainBackQueue becomes public for the GS.cpp seam.
Audit conclusions (no code needed): WaitGS callers never touch back-owned
state EE-side (state access travels through MTGS ring packets into already
drained seams); SIGNAL/FINISH are GIFRegHandlerNull in GSState — CSR
semantics are entirely EE-side; a back-thread assert failure aborts the
process on Linux, so it cannot deadlock the front.
Gates: gs_vertex_tests 21/21; gsrunner PNG hashes identical to GV-0
baselines, modes 0 and 3, vk and sw.
Co-Authored-By: Claude <noreply@anthropic.com>
The drain policy is the producer's; under the split the back object's own
flag stays lockstep while the front runs pipelined.
Co-Authored-By: Claude <noreply@anthropic.com>
The front parser object no longer drains after each push — the record ring
and pool backpressure bound the runahead, and the back thread executes
draws while the front parses ahead. Every cross-boundary read is either
record-carried, behind an explicit drain, or per-object:
- Serial counters s_n / s_transfer_n / s_last_transfer_draw_n become
per-object members (were process statics): the front assigns order and
carries serials in records, the draw executor installs s_n from the
record, and the transfer executor counts its own slice stream — so TC
timestamps and age heuristics on the back thread see the executing
draw's serial, not the front's runahead position. Qualified static refs
in the TC/MultiISA/OGL-debug/SW-dump paths now go through the renderer
object. The front re-syncs s_n and the scanmask after each (drained)
vsync.
- Kick-time IsCoverageAlphaSupported drains before reading last-flushed-
draw state (exact AND deterministic: post-drain state is a function of
the record stream, not thread timing; the alpha clause can read CLUT
bytes so it is not front-computable). Memoized per (draw serial, live
ALPHA) => at most one drain per AA1 draw.
- The HOST->LOCAL exec cursor stays back-side: the front skips the inline
m_tr.x/y mirror, Freeze adopts the drained back cursor before
serializing, and Defrost seeds it back.
- GSreset resets the front first (flushing pending draws as records) so
the back's drain executes them before memory/TC reset, like serial
pre-reset draws.
- Pipelined mode is refused (falls back to single-object lockstep) when
HWDownloadMode is Unsynchronized: that path reads local memory from the
EE thread with no drain.
Gates: gs_vertex_tests 21/21; gsrunner PNG hashes bit-identical to GV-0
baselines for modes 0/1/2/3 x vk+sw over all 10 dumps, and mode 3 repeated
3x with identical hashes (pipelining is deterministic by construction).
Co-Authored-By: Claude <noreply@anthropic.com>
Instantiate a GSFrontState parser object under GSBackThreadMode::Pipelined
(SEAM-AUDIT.md $7): it owns all parse state and emits records into the back
renderer's channel; the back object executes them, installing record state
into its own members so the HW look-ahead heuristics read the same names
they always did. Mode 3 still drains per record (lockstep) — the pipelined
flip is the next commit.
- GS.cpp routes GIF transfers, SoftReset, CSR, readbacks, savestates, and
the vsync PCRTC digestion to the front; present/TC/settings stay on the
renderer. The front is created only when the back thread engaged, and is
destroyed first (it drains the shared channel the back owns).
- Drained seams reach authoritative memory through m_mem_target: readback
ReadImageX/SaveBMP, InvalidateLocalMem, savestate vm8 serialize/restore,
TC readback/purge (now draining), plus back-side Reset/CLUT-reset and a
PCRTC re-sync on Defrost.
- The draw executor on a split back aims m_draw_env/PRIM/m_context around
the tail exactly as FlushDraw does on the front, and restores after.
- m_channel_shuffle_finish is written on both sides; the front's ApplyTEX0
set becomes a one-shot edge OR-ed into the back-owned flag (a level
install clobbered the draw path's own sets/clears — FlatOut 2 lost its
channel-shuffle skip, caught by the vk hash gate).
- Kick-time IsCoverageAlphaSupported reproduces single-object mixed
semantics: live PRIM/ALPHA from the front, last-executed-draw primclass/
cached-ctx/alpha-minmax from the back (IsRTWrittenLive split).
- GSAllocateWrappedMemory drops its process-global singleton (close the
fd/handle once the views are mapped) so two GSStates can each own a
wrapped vm; also fixes a handle leak in the Windows free path.
- s_transfer_n moves to the submit side: transfer serials are
front-assigned, and the vsync idle-frame check reads them on the MTGS
thread.
Gates: gs_vertex_tests 21/21; gsrunner PNG hashes bit-identical to GV-0
baselines for modes 0/1/2/3 x vk+sw over all 10 dumps (mode 3 exercises
Defrost + readback seams through the front object).
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>