The two table_watch timelines agree byte-for-byte up to one moment
(baseline fingerprints identical), then fork: entering the scene, native
receives two ch3 DMA loads (74 then 339 words) leaving 216 raw colour
words in the effect's table, while DuckStation's region goes to the
correct 5-colour table -- and both sides hold that state for the whole
~1220-frame effect before converging again (216->254->129 vs 5->252->129).
psx-runtime's cd_read_log records every ch3 load with LBA, dest and
size. This finds the loads that intersect the table and reads those very
sectors from the ISO. Disc holds the many-colour data -> native
delivered what was asked and the divergence is upstream (why the game
requested it, or where the DMA pointed). Disc holds something else ->
the CD stack is delivering wrong sector data, and that is the bug.
The log dies with the process: capture after playing the scene, before
restarting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
slice_check overturned two things at once: all 256 colour words ARE
rewritten every frame, and the writes themselves carry 152 distinct
values -- so the colour routine reads a per-vertex source table, and
vertex_taps' uniform loads were just its 64 probe slots landing on the
first few quads. That reinstates the finding withdrawn earlier: the
table at 0x800E25F0..0x800E2C60 is one per-vertex array, DS's $s4
(0x800E2C04) is inside it, native's copy holds ~216 distinct colour
words against DS's ~5, and DS renders correctly from its copy.
Write-tracing the table never fires, and CD-ROM DMA was checked: ch3
moves words through psx_write_word, so a disc load WOULD be traced.
What is not traced is anything that memcpys RAM wholesale -- savestate
and rewind restores above all. The user syncs the two emulators by
lining up savestates: if native's state file carries the corrupt table,
no writer exists in this session at all.
So watch the data, not the writers: poll the region's fingerprint while
the user plays in from as far back as possible, report every content
transition with the distinct-colour count either side, and attribute it
against the armed write trace. A transition with traced stores names the
writer; one with none is a wholesale restore, and the corruption rides
in the state file that was loaded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vertex_taps settled the load side: per frame the routine loads ONE
source pair -- (248,136,8) -- and one scale, and the 'second word' is the
mult product itself (0x5160 = 84*248, matching $s6=84 exactly). Loads,
products, scale, and all four unaligned helpers verified correct; the
dirty-RAM interpreter's tables are correct too, and the probe firing at
all proves this code is compiled anyway.
Yet the DMA'd buffer carries ~151 colours in one frame. The one
mechanism that reconciles that with everything measured -- and with the
hard-edged pie sectors on screen -- is amortisation: the game patching a
SLICE of the 256 colour words per frame, cycling, while $s6 falls 4 per
frame. Stale slices at different scales ARE brightness bands.
The write trace records frame and value per write, so this counts how
many of the 256 colour words are written each frame. ~256 kills the
hypothesis; a fraction confirms it and predicts the resident bands. If
confirmed, the game code being identical on DuckStation means the true
divergence is whatever drives $s6, and the next measurement is the
oracle's $s6 timeline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The oracle side is settled and the dual-emulator chase can stop: across
9 samples spanning brightness 3..220 its 64 additive quads carry exactly
3 colours, while psx-runtime's carry 122-153 at every brightness. What
is unknown is where native's extra values ENTER.
The probe records every GPR, so one frame of hits at 0x80068458 -- just
after the lwl/lwr pair -- yields per vertex the source pointer ($s4),
the loaded word ($t6) and the scale ($s6). At most ~4 distinct loaded
words per frame means the corruption happens between $t6 and the packet
(a dozen staging instructions); many distinct words means the pointer or
its table is already wrong before any arithmetic runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The run came back '0 ring frames carried additive shaded quads' while
the oracle collected six samples of the effect. psx-runtime is read
RETROSPECTIVELY from its GP0 ring and the oracle is watched LIVE, so
sampling the ring first captures whatever happened before the user
replayed anything. Watch the oracle first; by the time the ring is read
the effect has been played into it. A miss now retries across the whole
ring rather than the newest 600 frames.
Also scopes the truncation guard to windowed reads. It was firing on
full reads whose node count dipped -- which is not truncation, it is a
frame in which the game drew fewer primitives, normal and frequent
during an animation. Those were re-read in full for nothing and reported
as '13 windowed read(s) came back short', which reads as frames being
discarded.
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>
The '216 vs 5 distinct colour words at the same address' result does not
stand. psx-runtime's $s4 reads 0x800E260C and the oracle's 0x800E2C04 --
different addresses -- so comparing a fixed range compared what are most
likely two different objects' tables, not the same table twice.
Raw packet words confirm the decoder is right: 0x0003B790 really encodes
(144,183,3) and 0x3A000075 is command 0x3A with colour (117,0,0). But
only 46 of 256 vertices in one ring frame decompose as
source*k>>7 over the 3-colour table that was found, and the 'clean'
quads carry (2,0,150) and (87,0,0) which are not in it either. There are
several source tables; the probe caught one.
So compare the union instead. At any instant the two sit at different
points of the fade and their colour sets differ for reasons that are not
the bug; over the whole effect both traverse the same scales over the
same sources, so a colour one side NEVER produces at any point is real
while a colour merely absent from one sample is phase.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tracing 0x800E2718..0x800E2C60 across 2 frames of the effect recorded
nothing, consistently. That is a real result -- the table is STATIC
while the effect plays, which removes the phase caveat from the finding
that psx-runtime holds 216 distinct colour words there where DuckStation
holds 5, at the same addresses, 1067/1712 bytes differing.
But the old message said 'nothing wrote to this range... the code that
fills it is not running', which reads as 'nothing ever writes this' and
sends you looking for a missing writer. A table filled once at scene
load is simply not written during the effect that reads it.
--watch N traces free-running so the fill can be caught by walking into
the scene with the trace already armed. The empty-result messages now
distinguish the two cases, and the watched one names the remaining
possibility: a DMA fill writing RAM directly is not visible to a trace
built on CPU store hooks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
psx-runtime's $s4 covers 0x800E260C..0x800E2664 and reads a table of
exactly 3 colour words, identical on both emulators -- so this routine
can only ever emit 3 colours. Yet native's packets carry (117,0,0),
(106,0,0), (94,0,0), which cannot come from source (248,136,8) at any
scale: R=117 would force G=63, not 0. Something is reading a different
table.
DuckStation has pc_break (which captures every GPR) but no pc_probe, so
--oracle-pointer breaks once and reports which table its $s4 names. One
hit answers it: same range means the source cannot explain the packet
difference; a different range means psx-runtime is reading the wrong
table, which would be the bug itself.
Uses OracleBreak so the breakpoint cannot outlive the tool -- tested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run with --reg v0 it reported 'varies-within-frame' for a colour
computation that is provably correct. The probe arms a WINDOW of block
leaders rather than one instruction, so the six values it counted came
from six different PCs -- and they decode exactly right for source
(248,136,8) at $s6=80: 248*80>>7 = 155, 136*80>>7 = 85, 8*80>>7 = 5, and
the assembled word 939873691 = 0x3805559B carries precisely those bytes
under command 0x38. 136 and 8 are the source bytes mid-load.
The test is only meaningful for a value that should be fixed for the
whole frame -- the callee-saved fade scale it was written for. For a
scratch register, counting across PCs measures the program running.
Scratch registers now return not-applicable, and --per-pc breaks values
down by instruction so each can be read on its own.
The oracle's source table also came back byte-identical to psx-runtime's:
same 3 words, same counts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
$s6 is measured constant within each frame and ramping smoothly
(128,124,120,116,112,108), so the fade scale is not the source of
psx-runtime's ~153 distinct vertex colours against DuckStation's 3. That
also sharpens the target: at neutral scale the routine takes
'beq $s6,$t3' and copies the source word verbatim, so those 153 values
ARE the source table.
This probes $s4 to locate the table, reads it, counts the distinct
colour words actually in it, and can trace which code wrote it. Run
against --port 4371 to read the oracle's copy of the same table.
Either answer narrows: a uniform table means the fault is between the
table and the packet; a varied one means the wrong data is already there
before the colour routine runs, and the filler is the culprit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
packet_writers named the guilty code. Six PCs write the additive quads'
colour words, all running the same idiom:
lbu ,64() ; mult , ; mflo ; sra ,,7 ; sb ...
$s6 is the fade level, one value per frame, so every vertex in a frame
must see the same $s6 and the 64 quads should carry a handful of
colours. psx-runtime carries ~153 where DuckStation carries 3. A $s6
that changes WITHIN a frame explains that exactly: same source colours,
same arithmetic, a different scale per vertex.
Unlike every other comparison here this needs no oracle and no phase
lock -- 'constant within a frame' is checkable against one emulator on
its own, which is why it is worth a tool of its own after so many runs
lost to synchronising two.
$s6 is callee-saved and stays live across the swc2/NCLIP at
0x800684C4..0x800684D8, which is where a recompiled routine could lose it.
Ruled out first, by reading the implementations: mult computes eagerly
into cpu->lo and mflo reads it (no deferred-evaluation hazard), sb and
lw both handle scratchpad byte-exactly, and the lwl/lwr/swl/swr tables
are correct for little-endian R3000A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trace was armed only on the address range walked this frame, but the
game alternates between two packet buffers 0x8000 apart -- so the writes
it was waiting for went to the other copy and it reported 'no writes
were recorded' with the packets plainly present. Re-walking then landed
on whichever copy was current and every field looked like it had changed
role, which was reported as the map going stale when it was just a
normal buffer swap.
Both copies are now armed and dumped, write addresses fold back onto the
copy that was walked, the staleness check compares folded layouts, and
--frames defaults to 2 and is rounded up to even so the walked copy is
definitely rebuilt at least once.
The two copies are BUFFER_STRIDE apart by ADDITION, not by a toggled
bit: 0x0010D078 and 0x00115078 differ by 0x8000, but XOR gives
0x00105078 because the add carries out of bit 15. The first version of
this used XOR and folded real writes onto addresses that do not exist; a
test now pins the difference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A plain gpu_display_list walk of the oracle during the effect shows its
primitives reaching from 0x0363B0 to 0x1B23E0. The 128 KB window I added
around the ordering-table root covered almost none of that, so every
windowed walk was truncated to near-nothing -- identically on both reads,
so the coherence check passed it -- and the tool reported 'none held
additive shaded quads' for two runs straight.
The same walk confirms the oracle builds the effect: PolyFT4 584,
PolyF4+semi|B-F 184, PolyG3+semi|B-F 176, PolyG4+semi|B+F 64,
PolyG3+semi|B+F 32 -- matching psx-runtime class for class.
Windowing was a speed optimisation for a correctness-critical read and
it cost four of the user's runs. Off unless explicitly requested.
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>
The effect is a fade, so the load-bearing question is not how bright the
glow gets but whether it ever goes out. Records peak channel per sample
and the DIMMEST sample per object.
On the run already captured, native's colours hold a constant 31:17:1
ratio across samples -- [155,85,5] and [248,136,8] are the same source
colour, and 248*80/128 = 155 exactly. So source*$s6>>7 is being computed
correctly; only the scale differs. Applying the oracle's scale to
native's palette collapses 153 distinct colours to about 3, which is
what the oracle actually reports: same mesh, same source, same formula,
$s6 differing by roughly 64x.
Across 7 samples of the effect object psx-runtime's dimmest frame still
peaks at 155; the oracle's reaches 3. The verdict now says so -- but only
when the floors actually differ by 4x, so it cannot assert a fade fault
the numbers do not support.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first live run reported 153 colours against the oracle's 5 -- but
those came from different objects. merge() took maxima across every
sample, so the verdict compared psx-runtime's EFFECT (64 quads spanning
599 lines) against the oracle's PLACEMENT SCREEN (144 quads / 155
lines): two different things producing a number that looked like an
answer.
Samples are now grouped by (quad count, vertical span), which identifies
the object, and only objects BOTH sides sampled are compared. An object
one side never saw is a gap in the evidence, reported as
no-common-object, not a difference between the emulators.
The conclusion survives and sharpens. Like for like on the effect:
64 quads / 599 lines psx-runtime 153 colours oracle 3
144 quads / 155 lines psx-runtime 4 colours oracle 5
The placement glow agrees; the effect does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs from the live run.
The crash: gpu_display_list's report() formats verts as the string
'(x,y) (x,y) ...' while the GP0 ring dump carries [[x, y], ...]. A tool
that reads psx-runtime from the ring and the oracle from a RAM walk
gets both, and signature() indexed the string shape as if it were the
list shape -- v[1] on a string. The 'has 3 vertices' guard did not stop
it because a 5-character string is longer than 3. Both shapes are now
parsed explicitly, with a test asserting the two produce an identical
signature.
The wedged oracle: a paused DuckStation serves its socket from a Qt idle
timer at about 1 Hz, so pc_unbreak can simply time out -- and __exit__
swallowed that DebugError, leaving the breakpoint live. Since the
breakpoint re-pauses on its next hit, every manual unpause bounced
straight back. Removal is now verified against pc_break_list and retried,
and if it still cannot clear it says so and names restarting DuckStation
rather than exiting quietly with the emulator wedged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
effect_palette's pc_break came back {'ok': False, 'slot': 1} and the
oracle was left paused. Both symptoms are one cause: phase_parity armed
a breakpoint per loop iteration and returned early without removing it,
so 0x8006844C stayed live in the oracle across runs. DuckStation's
AddBreakpointWithCallback refuses a duplicate address (ok:false, and
slot 1 because slot 0 was still taken), while the leaked breakpoint kept
firing -- so every resume immediately re-paused. The leak lives in the
oracle, not the tool, which is why it survived restarts of the script.
Adds OracleBreak, a context manager that clears stale breakpoints on
entry, arms once, and removes plus resumes on EVERY exit path. Tools in
this tree have leaked a parked oracle repeatedly; a guard is the fix,
not remembering to resume. phase_parity still parks deliberately when it
finds its phase, but disarms first -- parked is a result, armed is a leak.
Also fixes OracleBreak's own error path, which raised a message saying
the oracle 'has been left running' without resuming it. Caught by the
test, not by reading it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured from psx-runtime's own ring, the additive Gouraud quads that
make the wedges carry a signature a correctly-rendering frame of the
same game does not:
good (renders right) 144 quads 5 colours 0 saturated 155 px
wedge (the effect) 64 quads 151 colours 68 saturated 599 px
Five distinct vertex colours against 151, and geometry spanning 599
lines on a 240-line screen.
Every frame-against-frame comparison here has foundered on animation
phase, so this compares a signature instead: how much colour variety
and geometry spread the effect's quads carry is a property of what the
list BUILDS, not of where the fade is -- the same reason class counts
were phase-robust while pixel diffs were not. 5-vs-151 does not drift.
psx-runtime is read from the GP0 ring (retrospective, nothing paused);
the oracle has no ring, so it is parked on a PC that only executes
during the effect and its ordering table is walked out of guest RAM.
That is what lands it inside the animation without anyone timing it.
The class is matched by properties, not op-name spelling, because the
ring dump and the RAM walk spell it differently and a class matching
nothing would report a clean signature for a broken frame. Missing
oracle samples return no-oracle-samples, never 'agree'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first run ranked 120 frames that were byte-identical (4 big untextured
polys, hub 2, 5 colours) and saved frame 21501 -- which has additive=0.
bad.json has 258 additive prims and good.json 180, so the window scanned
held no glow at all: the animation was never inside it, and the ranking
was a ranking of copies of one static frame.
Two fixes. Count blend modes per frame, and say plainly when a window
contains no additive primitives instead of printing a confident ranking
over identical rows -- 'the effect did not play inside this window' is
the finding there, not whichever frame sorted first. And default --last
to the whole ring (stride 4), since the effect is usually not in the
newest frames; --last 240 covered 21261..21501 while the ring held from
19917.
Adds --rank glow to sort by additive count directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wedge_watch never fired. Its threshold had to be calibrated against a
wedge frame's packet list, which is the one thing nobody has captured --
so it was calibrated against bad.json and good.json, and good.png turns
out to be the land-placement UI from before the effect even starts.
Both negatives, no positive: a guess wearing a measurement's clothes.
Replace it with a retrospective scan. The ring holds several hundred
frames, so the wedges are still in it seconds after they flash: play,
see them, then profile every frame backwards and RANK. Whatever the
wedge frame is, it sorts to the top; it does not have to clear a number
I invented. Large textured polys and the fan hub are reported next to
the untextured count, so a wrong guess about which class carries the
wedges shows up in the table instead of being filtered away silently.
A flat ranking is a real answer too: it means the lists are ordinary and
the fault is in rasterisation, not submission.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The land-creation effect's failure moment (full-screen hard-edged wedge
fans) had never been captured as a packet list -- bad.json is the dark
early frame, and racing the transient by hand kept missing it. This
watcher polls the ring while the scene replays and triggers on what a
wedge is made of: >= 10 untextured polygons spanning >= 120 px in one
frame. Healthy frames of this very scene stay under 5 (the vignette
ring), measured against bad.json/good.json and locked in by tests.
Strided backwards sweep between polls so a flash shorter than the poll
interval is still caught; nothing is ever paused. On trigger it saves
the dump, summary, screenshot and N context frames, then exits (or
--keep-going). Opaque and flat-shaded wedges count too -- 'hard-edged
solid polygons' may not carry the semi flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
oracle_phase accepted whatever value the oracle first broke at. The fade
register rests at its neutral 128 for most of the scene, so locking both
emulators to 128 does not establish that they are at the same moment --
the resulting image comparison was measuring two arbitrary frames.
Resume and re-break until the oracle stops at a value that is not in the
reject set (--exclude-phase, default 128), and report failure when only
common values are ever seen rather than returning a meaningless lock.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asked whether the bug can be fixed now. Not yet — there is a symptom and no
cause, and guessing at this point would join the six hypotheses already dead.
But the captured lists narrow it further without touching an emulator. For
PolyG3+semi|B+F:
strides psx-runtime [28, 2332] oracle [28, 1052, 2332]
range 0x10D05C..0x10DCC0 0x10D0AC..0x10FD48
colours (30,0,0) (24,0,30) dark (47,23,0) (127,55,0) bright orange
28 is one packet, so those are contiguous runs. The oracle has a THIRD group
the runtime does not — the 1052 stride — and builds it in a byte range the
runtime never touches. The colours differ in character too: dark red/blue
against bright red/green, which is what the screenshots show as missing orbs.
So the census now records the address span each class is built in, and when a
class is systematically absent it names the region the oracle uses and the
runtime does not. That converts "short by 258 primitives" into an address range
to point range_writers at: if nothing on psx-runtime writes there, that is
where they are lost, and the code that should have written it is the fix site.
Still a single capture, so still to be confirmed by the census itself — phase
can hide a group in one frame, not in forty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Looked at the two screenshots properly for the first time. The oracle draws a
dark scene with two bright glowing orbs and a green artifact on the landmass.
psx-runtime draws the same landmass, uniformly bright orange, with no orbs and
no artifact. That is the reported symptom verbatim — "it stops rendering the
artifact" — and it is not a glow rendered wrongly, it is objects that are not
there.
Which sends the class-count difference from a very different angle. Every class
matches exactly except two, and both are additive:
PolyG3+semi|B+F 32 against 290 -258
PolyFT4+semi|B+F 0 against 30 -30
Additive gouraud triangles and additive textured quads: exactly what draws
glowing orbs and a shimmering artifact. I saw this difference earlier and
attributed it to animation phase, because a single capture from each side
cannot tell "does not draw it" from "was not drawing it just then".
class_census removes that ambiguity by comparing MAXIMA over many captures.
Phase can hide a primitive in one frame; it cannot hide it in forty. If
psx-runtime never once reaches a count the oracle routinely shows, "not just
then" stops being an available explanation.
Deliberately symmetric: a class the ORACLE never reaches is reported as its own
finding rather than as the expected one, and a floor keeps single-digit jitter
(PolyFT3 at 4 against 6) out of the verdict.
If this confirms, the fault is in the guest code that BUILDS those primitives —
which the renderer results have been pointing away from all along, and which
explains why every renderer-side hypothesis died.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fade question is settled: psx-runtime steps $s6 by 2 on CONSECUTIVE frames,
crossing its whole range in about 64 frames. A smooth sweep. Lockstep is clean
again over 2,755,940 blocks. So the guest side is exhausted and the image is
what is left — but the image comparison was measuring nothing.
91.9% of pixels differ, and that is what two CORRECT images produce when they
are offset. psx-runtime puts its visible framebuffer at VRAM (0, 0); the oracle
puts its at (1, 8) — consistently, in both halves of the double buffer (0/240
against 8/248). Each screenshot is that side's own display area, so pixel (0,0)
of one is not pixel (0,0) of the other. A 1-pixel horizontal and 8-pixel
vertical shift makes almost every pixel differ, and it was being read as a
rendering fault.
The images are now cropped to a common frame using each side's reported origin,
with the 240-line buffer-half jump taken modulo the frame height: that is
double buffering, not displacement, and treating it as displacement would crop
the entire frame away.
Frame alignment was also failing silently for two reasons. run_to_frame ARMS a
target and does not block, so the frame was read back straight afterwards and
of course had not moved. And more fundamentally, the two were 1818 frames apart
— those numbers count from each emulator's own boot, so closing that gap would
not put them at the same guest moment, only run one of them past the effect.
A gap beyond 120 frames is now reported as unbridgeable rather than attempted.
The remaining state differences are real and worth noting: width 320 against
319, height 240 against 224, and gpustat differing only in bit 31, the
interlace field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First clean run — both sides sampled, no errors, no fallback:
psx-runtime 72, 74, 76, 78 steps of 2
oracle 12, 16, 20, 24 steps of 4
Both sweep smoothly; neither jumps. The ranges are disjoint (72-78 against
12-24), which is the two sides sitting at different points in the fade, not a
fault.
But the report could not say whether psx-runtime's series was per-frame or
free-running, because native_sampling was only set on the fallback path. Those
support completely different claims: steps of 2 across CONSECUTIVE frames is a
smooth fade, while steps of 2 across samples ninety frames apart is nothing at
all. The field is now set before either path runs and overwritten by whichever
one did.
And the eight-sample floor was applied to per-frame data, where it does not
belong. Consecutive frames measure the increment DIRECTLY rather than sampling
a distribution — four adjacent frames stepping by 2 IS the step. The floor
still applies to free-running samples, which say nothing however many there
are. Applying it to per-frame discarded a measurement that was already
conclusive.
A per-frame result now also states what the increment means: at a median step
of 2 the scale crosses its whole range in about 64 frames, roughly a second,
which does not produce hard bands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The report contradicted itself: native_sampling "free-running-fallback" AND
granularity "native-measured-per-frame" in the same document — the data is
aliased, and its steps are the animation increment. Both cannot be true. The
guard existed and sat on a different branch than the one that fired, so the
claim I said last commit could not be made was made on the very next run.
Free-running samples land about ninety frames apart, so their differences are
aliasing. Presenting them as an increment invites precisely the wrong
conclusion — that our fade jumps by 44 — when nothing measured that. Every
per-frame claim now checks the sampling method, a test asserts that for each
one by reading the source, and a fallback run gets its own label saying the
steps mean nothing.
The other half is why the fallback ran at all. Per-frame now reports "a block
leader fired but no register sample came with it", which was accurate and
narrow: it insisted on the SINGLE nearest leader and threw away frames where a
leader fired but the recorded sample belonged to a different one. It now takes
a sample from whichever fired leader has one, nearest first — true of the frame
rather than of one address.
Data so far, with the caveat attached: psx-runtime 26..128, oracle 76..86, both
aliased. Neither says how either one moves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Find colour writers keeps pausing the native and leaving it paused, causing it
to fail to find the writer."
The class-absent path returned from the middle of the parked section without
resuming. So the game stopped advancing, the effect never came round again,
every retry walked the same stale display list and reported the same "no
PolyG4+semi in the current list" — and the cause was invisible from outside,
because the message describes the list rather than the emulator being stopped.
It is self-perpetuating: the first failure guarantees the next one.
Leaving it parked is worse than any error this tool can report, so it is now
structurally impossible rather than remembered. The park spans the walk AND the
trace by design -- the layout has to still describe the buffer when the writes
happen -- so a try/finally around either half alone was never enough. The
parked section is a separate function wrapped in a guard, and all seven of its
exits resume.
The guard resumes once even when the trace block has already handed back
explicitly, and does not swallow the exception it is unwinding: a failed resume
must not replace the error that caused it.
Audited the other tools for the same shape. range_writers, scale_trace,
gpu_parity and gpu_display_list all resume in a finally; colour_inputs and
probe_regs never park psx-runtime at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fallback worked — psx-runtime produced 12 samples free-running. The oracle
still reported "PolyG4+semi never appeared within 120s. Drawing instead:
nothing", and the cost of the check is why.
class_on_screen() snapshots all 2 MB of RAM: 128 read_ram calls. Against a
PAUSED oracle, whose socket falls back to a ~1 Hz idle timer, that is 128
seconds for ONE poll. A 120-second wait therefore completes a single partial
read and reports "nothing" — not a statement about what is drawing, a statement
about a read that never finished. The oracle was very likely parked from an
earlier breakpoint run, which nothing in the wait undid.
So the wait now resumes first, pays for the full scan once, and then re-reads
only the span the display list occupies — a couple of KB, one or two reads
instead of 128. A cached root that stops walking means the list moved, and the
next poll pays for a rescan rather than sticking to a dead pointer.
A `check` seam lets a caller supply its own probe, which is also how this is
tested without standing up a synthetic display list in fake RAM.
Recorded for the next run: psx-runtime's series came from the free-running
fallback, so its steps (median 12, max 64) are ALIASED — samples ~90 frames
apart — and are not an animation increment. native_sampling says so in the
report, and the per-frame verdict is suppressed when the fallback ran.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Repeated runs capturing nothing from psx-runtime while the effect was being
replayed, and the reported reason was wrong twice over.
The scene check ran AFTER the finally block resumed psx-runtime, so it reported
what was on screen seconds later rather than during sampling — and for a brief
effect that reliably says "not on screen" when it was there the whole time. It
now checks while still parked.
And per-frame mode capturing nothing is not the same as the code being
unreachable. The free-running probe demonstrably works here: it returned 4800
hits and real register values ($s4=0x800E2634, $sp=0x1F800234, scratchpad, as
the oracle also reports). So per-frame returning empty is a property of
stepping, not of the block. It now retries free-running automatically, which
produces data with a stated caveat instead of a fourth empty run — and records
which method produced the numbers, so aliased steps are never presented as an
animation increment.
The failure message also separates two causes that need different fixes: no
candidate fired at all (the block starts further back, or this overlay is not
running through COMPILED blocks — the probe only fires at compiled block
leaders), versus a leader fired but no register sample came with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"the effect is not on screen on psx-runtime, so this code never ran. Currently
drawing: PolyFT4 x584, PolyF4+semi x184, PolyG3+semi x176, PolyFT3 x151" — the
diagnostic added last commit, doing its job on the first run. The effect simply
was not up when the tool looked.
That is the real problem, and it is not a diagnostic problem. Every tool here
needs the effect RUNNING, and requiring it to be on screen at the instant a
button is clicked means racing a transient animation. Losing that race produces
a different-looking failure in each tool — no writes recorded, no candidate
fired, no table found, the block was not reached — four symptoms, one cause,
none of them naming it. Running lockstep beforehand appeared to help only
because it takes long enough for the effect to come round again.
So the tools wait. Press the button first, then trigger the effect; each polls
the display list until its class appears, up to two minutes, and each side is
waited for INDEPENDENTLY in its own thread. That last part matters: it removes
the need to hold two emulators inside the same effect simultaneously, which was
never realistic by hand.
Wired into scale_trace, packet_writers, range_writers and colour_inputs — every
tool that needs the effect. Timing out reports what WAS drawing, because "wrong
scene" and "tool is broken" look identical otherwise. A transient DebugError
mid-poll does not abort the wait: the emulator can be mid-pause, and one failed
poll is not a reason to stop waiting for something that has not happened yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from a live session: DuckStation being paused repeatedly through the
animation while psx-runtime was not. Two things behind it.
Per-frame sampling never swept. It flattened every search window and took the
first sixteen candidates, which is one window — the nearest 0x3C bytes — so a
block starting earlier than that was never found. The failure then read "the
block was not reached on any of 12 stepped frames", which is true and points
the reader at the effect not drawing rather than at a search that barely
looked. Each stepped frame now arms the NEXT window, so the sweep advances with
the animation instead of being spent before it starts.
And on failure it ASKED whether the effect was drawing instead of checking.
class_on_screen answers that directly, and the two causes need opposite
responses: nothing on screen means get the game to the scene, a covered range
means the block starts further back. It now says which, and how far it looked.
The larger point is the one the observation exposes. Stepping psx-runtime frame
by frame while the oracle free-runs measures two different things, and for
"does OUR fade sweep or jump" the oracle is not needed at all — consecutive
frames give the real increment with no alignment, no shared phase and no second
emulator. Requiring both sides forced the operator to hold two emulators inside
the same effect at once, which is the hardest part of this whole exercise. A
per-frame series of five or more samples now stands on its own, and says
plainly whether our fade is smooth or coarse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs, both reported from a live session: psx-runtime visibly parked while
the oracle ran on untouched, and the oracle then said it had no samples.
The sides were sampled SEQUENTIALLY. psx-runtime was sampled to completion
first and the oracle asked afterwards, by which time the effect had finished
over there and the breakpoint could never fire. Replaying the animation does
not help, because the two halves of the run are minutes apart. They now run on
threads and are watched during the same pass.
And per-frame sampling could only ever capture one value. It paused
psx-runtime, then called probe_registers — which arms the probe and SLEEPS
waiting for it to fire. On a paused emulator no code executes, so the block
leader is never reached and every sample after the first comes back empty. That
is where the "1 sample" run came from, and the verdict drawn from it.
The order has to be arm -> STEP -> read, so the block executes between arming
and reading, and sample_per_frame now does that itself rather than delegating
to a helper whose whole method is waiting for a running emulator.
Both are pinned by tests that read the source order directly, because both
failures produced plausible output rather than an error: one sample looks like
a sample, and an empty oracle looks like an oracle that was not there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tool reported "native-not-animating" from ONE psx-runtime sample, with the
note "never moved from 128 across 1 samples". That is not a claim anyone can
make, and it agreed with a hypothesis already disproved twice here — the most
dangerous shape a bug can take, because it confirms what the reader suspects.
The cause was the previous commit. Caching the block leader to make sampling
affordable REPLACED the sweep instead of preceding it, so the moment the cached
address stopped firing — an overlay reloading elsewhere, or that block simply
not being reached on a pass — the run ended. A 24-sample request became one
sample, and then a conclusion was drawn from it.
Both halves fixed. The cached leader is now tried first and the full sweep
still follows it, and a failed sample drops the cache and continues rather than
ending the series. Any "constant" verdict requires at least five samples from
that side; below it the result is too-few-samples, stated as such. Variation
needs no floor, because two different values prove movement however few samples
there are.
The oracle's own series is worth recording: 112, 116, 120, 124, 128 — five
distinct values in even steps of 4. Whatever psx-runtime does, the oracle
sweeps its fade smoothly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First complete both-sides comparison, and the verdict is both-animate: the fade
runs on psx-runtime too. That is the second time the pinned-scale idea has been
disproved by measurement, and it stays disproved.
The shapes differ though, and min/max cannot express it:
oracle 122, 124, 126, 128 steps of 2, a 6-wide band
psx-runtime 28, 128 nothing between, a 100-wide jump
A fade that sweeps smoothly and one that jumps between extremes produce very
different pictures from identical geometry — which is the reported symptom. So
describe() now reports step size alongside range, and both-animate compares
granularity rather than stopping at "both vary".
It refuses to compare granularity below eight samples per side, because two
distinct values out of three is ALSO exactly what sparse sampling of a smooth
ramp looks like. The run above had three and four; that is suggestive and
nothing more.
Which is why sampling was the real problem: every psx-runtime sample re-ran the
whole block-leader sweep, so a run capped out at three. The leader is found once
and reused, and the default sample count is up to 24.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The memory card worked — the oracle reached the effect and produced samples for
the first time: $s6 at 116, 120, 124, 128, a smooth descending ramp. That is
the fade animating, and it is the first cross-emulator measurement in this
investigation that was not blocked by the two sides being in different places.
psx-runtime then failed, at a different colour writer: none of its 16
candidates was a basic-block leader. The message was accurate and useless. A
single fixed window (step 0x10 over 0x100) is a guess about where the enclosing
block starts, and it found a leader for 0x8006844C while finding none at all
for 0x800684C0 — two writers sixteen bytes of code apart.
Now it sweeps: sixteen candidates at 4-byte steps immediately before the
target, then successive contiguous windows further back until something fires.
Every instruction is a possible leader, so the step has to be 4 — a coarser one
can walk straight over the only address that would have fired, which is what
happened. Windows are contiguous by construction, because a gap between them
hides exactly the same thing.
Failing now reports how far back it actually searched, rather than naming a
step and a range the operator has to reason about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both failure reasons are now recorded, and together they name the blocker that
has stopped every cross-emulator measurement at this effect:
oracle: did not reach 0x8006844C while sampling. That PC is overlay code, so
it only exists while the right overlay is resident and the oracle has
to be at the same point in the game.
psx-runtime gets driven to the effect by hand; the oracle sits wherever a fresh
boot leaves it, and a breakpoint on an overlay that is not loaded can never
fire. Nothing about the tooling fixes that — the oracle has to actually be
there.
The practical route is the game's own save. Native keeps its card at
saves/card1.mcd; the oracle defaults to PerGameTitle in its own directory, so
it boots with an empty one. `start --memcard` copies the card in and switches
Card1Type to Shared, and the Studio passes it automatically when a card is
present next to the project. The oracle can then load the player's save slot
and arrive at the same place — far more practical than replaying input from
boot or moving a savestate between two emulators that do not share a format.
Copied rather than shared in place: two emulators writing one card is a good
way to lose a save, and the reference copy only needs to be read. A missing
card is reported, because booting with an empty one looks exactly like a
working oracle that simply never reaches the scene.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
psx-runtime's $s6 read 66, 72 and 128 across three samples. It varies. The fade
animates on our side too, so "the scale is pinned at the neutral value" — which
the last commit put forward on the strength of three separate single-sample
runs all happening to catch 128 — is disproved.
That is worth stating rather than quietly moving on. Three single readings
taken for other purposes looked like a pattern; the first tool that sampled
repeatedly killed it immediately. The lesson is the same one this investigation
keeps teaching: single samples of an animating quantity are not evidence about
it, and the fix is always to measure variation rather than values.
Second problem, in the tool rather than the theory: the oracle produced no
samples and the report said only "oracle": null, verdict "incomplete". That
states there is no comparison without saying whether the emulator was
unreachable, never reached the PC, or refused the breakpoint — three causes
needing three different responses, and the reason is known at the point of
failure and nowhere else. Both samplers now return their reason alongside their
values.
It also threw away the half that DID answer, which in this run was the half
that disproved the hypothesis. A one-sided result is not a comparison, but it
is not nothing either, and the Studio now says so explicitly instead of showing
an empty panel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The phase test came back tested: false — psx-runtime's scale never left 128
during the sampling window — and the tool then reported "region-differs"
anyway, stating as established the exact thing it had failed to check. Fixed:
an untested phase question now downgrades the verdict instead of being ignored.
But a scale that refuses to move is a finding, not a failed setup step. Across
every run the oracle's has come back different — 52, 64, 68, 76, 96, 126, 128 —
while psx-runtime's has read 128 each time colour_inputs sampled it. And 128 is
the NEUTRAL scale: x*128>>7 leaves x unchanged, so at that value this routine
applies no fade at all. Bright unfaded polygons are exactly what the code would
then produce, which is the reported symptom.
scale_trace samples $s6 on both emulators and compares VARIATION rather than
values. That is the point: it needs no frame alignment, no matching buffer
half, and no shared animation phase. Those three have each produced a confident
wrong answer in this investigation, and a register that sweeps on one side
while sitting still on the other is a difference none of them can manufacture.
Four outcomes, kept apart because they point four different ways: ours pinned
and theirs sweeping (the suspected fault, called out specially when the pinned
value is the neutral one), both sweeping (the fade runs, so the difference is
in the values), both constant (most likely nothing is animating right now, and
that says nothing), and theirs pinned while ours sweeps (the opposite of the
expectation, not to be reported as if it confirmed it).
This is a HYPOTHESIS with supporting observations, not a conclusion. One
probe_regs run did read 72 on psx-runtime, so the register is not always 128
and a single reading either way settles nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The characterised difference is not conclusive, and the shape says why. At
0x000E4E00 the two hold the SAME palette entries offset by exactly four bytes;
elsewhere psx-runtime holds f8 88 08 where the oracle holds f8 f8 b0; and in
places psx-runtime holds 16-bit negatives (-100, -98, -19 — coordinates) where
the oracle holds colours. That is consistent with a real layout divergence AND
with the two being at different points in the animation, which they are:
scale 128 against 72.
The churn check does not settle it. It re-reads after 0.4s, so it catches a
region being written continuously but not one rewritten only when the animation
STEPS — which is exactly how a region can report static: true and still be
phase-driven. Every "region-differs" result so far rests on that gap.
So: sample the region on psx-runtime at two different values of $s6. One
emulator, no alignment, no oracle. If its own copy moves with the scale, the
region is animation-driven and comparing it across two emulators at different
scales proves nothing — including every earlier result. If it does not move,
the difference is real.
A scale that never changes within the window reports "untested" rather than
"not phase dependent". Those are different, and treating the second as the
first would license the comparison on no evidence at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run now says region_static: true with region_churn_bytes: 0 and 2747 of
23088 bytes differing. By the tool's own criterion that is a real divergence —
the region is not mid-write — and the report still only carried a number.
A count is compatible with several completely different causes: psx-runtime has
not written the data at all, psx-runtime is writing where the oracle does not,
or both hold values that disagree. Those point in different directions, and
"2747" cannot be read to tell them apart.
The report now carries the differing runs with both sides' bytes side by side,
coalesced so that a 4-byte entry differing in three of its bytes reads as one
finding rather than three, plus a plain statement of the shape:
* zeros on one side -> that side has not written this
* data on both -> a genuine disagreement, and whether it falls on
4-byte boundaries (whole entries) or not (stray bytes)
Also worth recording against the earlier reading: with the effect OFF the same
region is byte-identical and completely static across eight samples. So this
difference appears with the effect, which is what makes it interesting rather
than ambient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The colour investigation reached a REGION rather than a structure. With the
effect off, 0x000E0BF8..0x000E6628 is byte-identical between the two emulators
and completely static; with it running they differ. So the base colour data
agrees and the divergence is in what the effect WRITES there — which makes
"what writes this range" the next question, and packet_writers could not ask it
because it assumes a packet layout to classify fields.
Same instrument without that assumption: point it at any span and it reports
which instructions store there, how many times, over what address span, and
with what values. The span matters because a store walking an array is a
different thing from one hitting a single field, and the values matter because
a writer storing the same constant everywhere is clearing while one storing
varied values is computing.
Same two disciplines as packet_writers, for the same reasons. Tracing happens
over a bounded number of STEPPED frames, since the game rebuilds its buffers
constantly and a free-running trace attributes writes to whatever the addresses
meant at some other moment. Code is captured while parked, because these are
overlay addresses and the same PC decodes differently once another overlay
loads.
It also refuses to report an empty trace as a result: if the class that drives
this code is not on screen it says so and names what is drawing instead, rather
than returning no writers and letting that read as "nothing writes here".
The Studio prefills the range from whatever Compare colour inputs last
measured, so it does not have to be copied by hand between panes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture works now — $sp reads 0x1F800234, scratchpad, matching the oracle —
and the run finally compared through both emulators' own pointers. It reported
"source-differs", and that is still not trustworthy.
$s4 MOVES. Across two samples psx-runtime's went 0x800E2634 -> 0x800E4634 while
$s6 went 72 -> 128; the oracle's moved the same way. The pointer tracks the
animation, so a window at each side's own $s4 compares different ENTRIES of the
same array whenever the two are at different moments — which they always are.
The 0x1A30 gap between the two source addresses is not the 0x8000 double buffer
and not a divergence; it is elapsed time.
This is the third time a phase difference has been reported as a data
difference (frame numbers, buffer halves, now array offsets), so the fix is the
same shape as before: measure something phase cannot move. When the two scales
disagree, the whole enclosing region is compared instead. If the arrays match,
the data is fine wherever each pointer sits; if they do not, that is real and
independent of where either side happened to be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every register came back 0x00000000 — including $sp, which cannot be zero in
running code. The struct had gpr[32] and the JSON emitted it; nothing ever
wrote to it. The patch that added the fill did the replacement in memory and
never wrote the file, and the next edit re-read from disk without it. Two
thirds of a change, compiling and running and reporting 32 zeros.
Zero then flowed all the way downstream without anything objecting: 0 minus the
load offset masks to 0x1FFFFFF4, which looks like an address, so colour_inputs
read "psx-runtime's table" from unmapped memory and reported a difference
against the oracle. The same document carried "differing_bytes": 0 and
"source_identical": false — the read had come back short, so zip() compared
nothing while the buffers still differed.
Three guards, because one bad value crossed four steps unchallenged:
* probe_registers refuses a capture where every register is zero and says so,
rather than returning zeros as data
* plausible_pointer() rejects anything that is not a RAM address before it is
used as one, including the masked underflow that got through
* the comparison refuses unequal read lengths instead of reporting a
difference between a buffer and nothing
Scratchpad is deliberately not a plausible table pointer: $sp lives there, and
accepting it would compare the wrong region while looking entirely reasonable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>