147 discarded, 0 accepted, while the walked lists plainly contained
PolyG4+semi|B+F x144 -- the very quads being looked for. The 90% node
count re-check rejects every frame in which the list changes size, and
during an animation that is every frame.
This is the THIRD second-read test to fail the same way: byte identity,
then list shape, now node count. Each was narrower than the last and
each discarded exactly the frames it existed to capture. The pattern is
the problem, not the threshold -- any comparison between two reads taken
a moment apart rejects a list that is being rebuilt, which is what a
list being drawn looks like.
A 256 KB scoped read is ~16 chunks, comparable to a single frame, so the
snapshot IS a coherent picture and needs no second opinion. Unscoped
reads keep the existing check. Also survives an emulator that is not
running instead of raising ConnectionRefusedError out of main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A whole animation yielded one capture. With windowing off every sample
snapshots all of RAM -- 128 socket round trips, seconds each -- and the
effect is over in a handful of them. The strictness was not the problem
this time; the sample rate was.
The additive quads live in the packet buffers at ~0x0010Dxxx and
~0x00115xxx and the ordering tables at ~0x129xxx and ~0x1316xxx, all
inside 0x00100000..0x00140000. Reading just that is ~16 chunks instead
of 128. Primitives outside it are not seen, which is exactly why this is
an explicit span rather than the root-relative window that silently
truncated earlier runs -- and why 'all' remains available.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three faults from one run.
The coherence check was rejecting the effect, not failing to reach it.
Requiring two reads to agree on the list SHAPE rejects every frame in
which the list changes size -- during an animation, all of them. That is
the same mistake as the byte-identity check it replaced, one step
weaker: both kept only the frames WITHOUT the animation and reported the
rest as torn. A torn read is malformed, not merely different, and
walk_ordering_table already refuses entries whose word count does not
match their opcode -- so the walk succeeding IS the check, at one read
instead of two.
The quota counted any additive-quad sample, so twelve samples of the
land-placement glow (144 quads / 155 lines, on screen before the effect
and always readable) filled it and the verdict was drawn from the wrong
object -- reporting 'signatures-agree ... the wedges are produced when
this list is RASTERISED' about a list that contains no wedges. --object
defaults to 64x599 and only matching samples count; others are reported
as skipped.
And the union set crashed json.dump; it is stored as a sorted list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Byte-identity was the obvious check and it is exactly backwards. While
the effect plays, the game rewrites its list every frame, so two
consecutive reads never match -- every ANIMATED frame was flagged torn
and discarded, leaving only the frames without the animation in them.
A real run read 172 walks after the effect had been played several
times and found additive quads in none of them.
A genuinely torn read is not merely different, it is malformed: packet
word counts stop matching their opcodes and entries drop out of the
walk. So the two reads must now agree on the SHAPE of the list -- entry
count, and each entry's opcode and length -- while the data inside is
free to differ, because colours changing between two reads is the
effect working, not corruption.
A test asserts the byte-identity check cannot come back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'nothing is paused' was false. walk_side parks the emulator on EVERY
sample for its coherent re-read, so effect_palette pause/unpaused
DuckStation repeatedly for the whole animation -- which is what the user
saw, and why it captured one frame out of eight.
Two back-to-back reads of the list's span that come back byte-identical
cannot have been rebuilt in between, so the picture is from one instant
without ever stopping the emulator. park_for_reread=False selects that
path; the CLI keeps parking, which is fine for a one-shot walk.
This also protects the finding under test. walk_side's own comment warns
that a torn read looks like 'a whole frame of additive quads carrying
just 4 distinct colour values' -- which is indistinguishable from the
oracle's 3-colour result. Torn samples are now detected and DISCARDED
rather than compared, and the count is reported. A conclusion drawn from
a read taken mid-rebuild would have been worthless.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects the last run exposed.
The oracle appeared to pause because every sample called walk_side,
which snapshots all 2 MB of RAM -- about 128 socket round trips serviced
on DuckStation's emulator thread. Nothing was paused; it was being read
to a standstill, repeatedly, and rarely landing in the animation as a
result. The list address is now cached after the first successful walk
and subsequent samples re-read only a 128 KB window around it.
Worse, the run reported 'signatures-agree' on 144 quads/155 lines while
psx-runtime had sampled the effect object ZERO times. Falling back to a
smaller object that both sides happened to see, and calling that
agreement, is the same failure as ranking 120 identical frames and
naming a winner. The biggest object either side saw is the effect; if
only one side sampled it, the verdict is now effect-object-one-sided and
says explicitly that smaller shared objects are not a substitute.
Also records that the oracle's effect object carries 3 distinct colours
at peak 3 AND at peak 220 -- colour count does not move with brightness,
so the 153-vs-3 difference is not a fade artifact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With both walks coherent, the native-vs-oracle pair says something clear.
Geometry matches: 761/800 paired primitives carry identical vertices, and all
64 additive quads of the effect match exactly. The guest builds the same
picture on both.
Colour does not, and the measurement that shows it is distinct-colour COUNT.
Every other colour comparison needs the two emulators on the same frame, which
nothing short of deterministic replay achieves. But a procedural effect reuses
a handful of colours across all its primitives, and that stays true at every
phase -- an effect at a different moment has DIFFERENT colours, not more of
them. So an order-of-magnitude gap is evidence about the computation rather
than about when each side was sampled.
For PolyG4+semi|B+F, the same 64 primitives:
oracle 3 distinct colours, 8 per-prim patterns
psx-runtime 153 distinct colours, 54 per-prim patterns
51x. And the clearest case needs no phase argument at all: PolyFT4|opaque, 584
primitives on both, two distinct colours on both, different VALUES --
(64,32,32)/(24,24,24) against (80,64,64)/(40,40,40). Those are static
background quads; they are not animating between the two samples. Meanwhile
PolyF4+semi|B-F matches exactly at (64,64,64) on both.
Flat colour agrees, shaded colour does not. That places the divergence in the
guest's colour computation, upstream of the renderer -- not in geometry, not in
blending, and not in the rasteriser.
compare() also tolerates a report with no per-primitive detail now, so an older
dlist.json still yields a class delta instead of raising.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both changes come from reading a real native-vs-oracle pair.
The good news in that pair: all 64 additive quads carry BIT-IDENTICAL vertices
on the two emulators, and every (opcode, blend) class count matches. Whatever
the render bug is, the geometry the guest builds is not it.
The bad news was in how the oracle's half was captured. Reading 2 MB from a
running emulator takes ~128 round trips, and the game rebuilds its ordering
table underneath -- that walk spanned 6 frames and came back with a whole frame
of additive quads carrying just 4 distinct colour values across 768 channels.
A torn read that looks exactly like data, and I nearly read it as evidence that
the runtime's colours were corrupt.
The fix is cheap now that the scan reports the list's span: the first pass finds
WHERE the list is, then the emulator is parked and only that span (a few KB) is
re-read, so the decoded list comes from ONE instant. A few KB is affordable even
at the 1 Hz the oracle drops to while parked, which is what made this impossible
when the read was the whole of RAM. Walks now report whether they are coherent,
and say THIS WALK MAY BE TORN when they are not.
gpu_colour_parity refuses a verdict past a 4x sample imbalance. It had reported
"differ" from 3328 vertices against 256 -- roughly thirteen frames of an
animating effect against a single one. Those differ by construction and say
nothing about the emulators.
Also fixes two tests that encoded the old ping-based frame(): one mock answered
every command with a frame field, so it answered `frame` too and quietly robbed
the test of the ping fallback it existed to check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Walking the two sides one after the other lets the effect animate in between,
so the class delta ends up describing elapsed time as much as any real
divergence. --both runs each side in its own thread (DebugConn opens a fresh
socket per command and holds no shared state) so the two snapshots land as
close together as the reads allow, then prints the per-class delta and writes
dlist-native.json and dlist-oracle.json.
The two sides are deliberately NOT symmetric: psx-runtime is parked for its
snapshot, because its park loop keeps serving commands, while the oracle is
read running -- pausing DuckStation drops its debug socket to the Qt idle timer
(1 Hz with no gamepad) and the read cannot finish.
Since the oracle is read running, each side reports the frame number either
side of its snapshot and warns when it advanced more than two frames during the
read: such a walk may straddle a rebuild of the list, and that should be
visibly suspect rather than quietly reported as fact.
The summary line now names only the files actually written. It previously
claimed to have written both even when neither side could be reached, which
would leave a stale file from an earlier run looking like fresh output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs the GUI surfaced, both from the same root: an edit that was assumed
to have applied rather than checked.
* gpu_display_list.py raised "NameError: find_display_lists is not defined".
A patch script added that import with an unasserted str.replace(); the
anchor text had drifted, the replace silently did nothing, and the file
still parsed and imported cleanly. It only failed when a user clicked the
button. Every other edit in that script asserted its anchor.
* gpu_colour_parity.py sampled nothing from the oracle ("no matching
primitives sampled") because its oracle path still resolved the display
list through DMA channel 2's MADR, which now correctly returns None -- a
finished linked-list transfer leaves the 0xFFFFFF terminator there. The
fix for that landed in the inspector and was never carried across. It now
scans for the list, locks onto the root, and re-reads only the span the
list occupies instead of 2 MB per sample. It also no longer pauses the
oracle: DuckStation pumps its debug socket from the emulation loop, so a
paused oracle drops to the Qt idle timer (1 Hz with no gamepad) and the
reads cannot finish.
test_tool_imports.py guards the class rather than the instance. It walks each
tool's BYTECODE for LOAD_GLOBAL and checks every name resolves, which reaches
branches no test executes -- an undefined global is only an error on the line
that runs it, so importing and parsing prove nothing. Verified by
reintroducing the exact bug: it fails with the missing name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All of this existed and was lost to a `git pull --rebase` in this submodule
while uncommitted. Committing so it survives the next one.
Engine:
* pause / continue / step / run_to_frame are real again, debug-server only —
no key, button, or menu reaches them, so a shipped build cannot be frozen.
The wedge that got them removed the first time is fixed: the park loop
keeps calling debug_server_poll() (which delivers `continue` and feeds the
4-second starvation watchdog) and auto-resumes after s_pause_timeout_ms of
client silence, so a debugger that dies cannot take the game with it.
* present_hold_tick() / is_window_repaint_event(): a frame held on screen no
longer rots when another window crosses it. Previously nothing presented
while stalled, so the expose went unanswered and the frame smeared.
Tools:
* psx_gpu_frame.py: frame signatures (compare frames by opcode+blend rather
than by function — a game that DMAs an ordering table reports one submit PC
for every packet, so function attribution has one answer per frame), plus
reading display lists out of guest RAM.
* gpu_display_list.py: walk an ordering table and decode it. Finds the list
by scanning for chained nodes whose payload is a real GP0 command of
exactly the right length. Following DMA ch2's MADR does NOT work: a
finished linked-list transfer leaves the 0xFFFFFF terminator there, which
masks to 0x1FFFFF and looks like a valid address.
* gpu_colour_parity.py: compare vertex-colour distributions across emulators.
Distributions rather than a diff because two emulators cannot be aligned to
the same instant without deterministic replay, and an animating effect
sampled at two phases differs everywhere — which says nothing.
* read_ram is capped at 16 KB/call: the oracle advertises 65536 but replies
over a socket that truncates it, and the read then hangs. Measured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>