Commit Graph

373 Commits

Author SHA1 Message Date
Alex Vanderveen 36ba6bd950 cd_fork: check the disc's own sectors against what landed in RAM
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen ce0f2b677e table_watch: survive Ctrl-C, run against the oracle
The first live run produced the decisive timeline -- the 216-colour
content arrives from CD-ROM DMA (0x8001FE80, ch3) at the scene load, so
the savestate theory is dead and the table holds raw disc data -- but a
Ctrl-C at the end lost the report, because it only saved on clean exit.
Every transition now flushes immediately.

Also runs against DuckStation: it has no wtrace, so transitions report
unattributed there -- which is all the oracle side needs, since the
question is WHERE ITS TIMELINE DIVERGES from native's: does DS's copy of
the region also become 216 raw colours after the CD load and then get
overwritten to ~5 by a later step that native never runs, or does DS's
CD load deliver different data in the first place?

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen afd5c17b98 table_watch: catch the table changing, whatever changes it
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen df88fc61aa slice_check: count colour-word writes per frame
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen bc5e27c37a vertex_taps: read what the colour routine loads, per vertex, one frame
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 64141b77d9 walk_side: a scoped read is the picture; stop re-reading to verify it
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen b27b2927fe effect_palette: read 256 KB per sample, not 2 MB
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 59ba682300 effect_palette: watch the oracle first, then read the ring
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen f8efa35630 effect_palette: stop rejecting the animation; filter by object; fix JSON
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen cb60a01f45 effect_palette: compare the UNION of colours, and withdraw the table finding
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 0371952721 range_writers: --watch for data written once at scene load
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 0b2040a387 colour_source: --diff-span, compare the same address on both emulators
The oracle's $s4 reads 0x800E2C04; psx-runtime's covers
0x800E260C..0x800E2664, about 1.4 KB away. That is suggestive but not
conclusive on its own: the samples came from different moments, and if
$s4 walks a larger table across the effect then a phase difference alone
produces it.

What decides it is whether the CONTENTS differ. --diff-span reads one
range from both emulators and compares the bytes, which is
phase-independent while the table is static: identical data means a
differing $s4 is just a different offset into the same table, while
differing data is a fault upstream of the colour routine entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 11f14a71c8 colour_source: read the oracle's source pointer via pc_break
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 106845956f scale_within_frame: refuse the constant-within-frame test for scratch regs
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 34895beb13 colour_source: --span, so the oracle's copy can be read at all
Native's source table is UNIFORM: 3 distinct colour words --
(248,136,8), (248,248,176), (0,0,0). Those are the oracle's
(147,80,4)/(147,147,104)/(4,4,4) at a different fade level: 248*147/248
is exactly 147, and 136 and 8 scale to 80 and 4. Source correct, scale
correct (constant per frame, measured), packets carrying ~153 colours --
so the corruption is between the table and the packet.

The oracle run failed with 'unknown command: pc_probe_clear': DuckStation
has pc_break but no pc_probe, so it cannot be probed this way. It does
not need to be -- the table is at the same address in both emulators, so
--span LO:HI reads it directly. The failure path now says so instead of
telling the user to replay an animation that would never have worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 2ab1200580 colour_source: locate the table the effect's colours are read from
$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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 85bb9304db scale_within_frame: check the fade scale is constant across one frame
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 28ea77bd91 packet_writers: trace both halves of the double buffer
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 2661e0f5b2 effect_palette: windowed reads off by default -- the list spans 1.5 MB
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen d73bd8df86 effect_palette: guard windowed reads and say what was walked
Two runs since windowing was added returned zero oracle samples of ANY
object, including the placement glow it had found before. A window that
does not span the whole list truncates the walk -- and truncates both
reads identically, so the coherence check passes and the missing
primitives are indistinguishable from primitives the game never drew.

The first walk of each run now always reads all of RAM and records the
list's true node count; a later windowed walk that comes back short is
re-read in full and the cached root discarded.

And when nothing is found it now prints the classes it DID walk. 'None
held additive shaded quads' is equally consistent with the effect not
playing and with the walk reading the wrong buffer, and those need
different responses -- reporting only the first reading was the tool
telling the user to replay an animation it may simply have been unable
to see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 9fcbfacb85 walk_side: coherence by list SHAPE, not byte identity
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen d6acf0d8cc walk_side: verify coherence by re-reading, never by pausing
'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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen a850110b45 effect_palette: stop reading 2 MB per sample; refuse one-sided objects
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 8c06ed8f58 effect_palette: track the fade floor, not just peak brightness
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 0f0cefce57 effect_palette: compare object-for-object, not maxima over everything
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 8a5df6cd1e effect_palette: stop pausing the oracle entirely
Parking DuckStation was the wrong design and it never worked. A paused
DuckStation serves its debug socket from a Qt idle timer at about 1 Hz,
so a list walk needing many round trips cannot finish; and a pc_break
that re-fires on its next hit re-pauses the instant anything resumes it,
wedging the emulator for the user as well as the tool. Two rounds of
fixes went into making the parking safe when the parking itself was
unnecessary.

The effect lasts seconds and the game rebuilds its ordering table every
frame, so the oracle is now read while it RUNS -- sampling often rather
than freezing time, which is how psx-runtime's ring side already worked.
psx-runtime is never paused either; it is read backwards out of the ring.

Adds oracle_unwedge.py to clear a breakpoint an earlier run left armed,
since that state lives in DuckStation and outlives the script that set it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 7168a3b52b effect_palette: read both prim shapes; verify breakpoint removal
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 29bc5bd85e oracle: guarantee breakpoints are removed and the oracle resumed
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 3069d87aea effect_palette: compare what each emulator BUILDS, phase-robustly
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 86b85fec2c wedge_scan: locate the effect by additive count; scan the whole ring
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 84cf9f6b64 wedge_scan: rank ring frames retrospectively instead of triggering live
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen b87679e5ad wedge_watch: auto-capture the giant-wedge frame from the GP0 ring
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen ecec7e28ff phase_parity: hold out for a mid-ramp phase, not the neutral one
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 14b1163b32 census: report WHERE a missing class is built, not just how many
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen b2a33b21ca census: the images show missing OBJECTS, not a mis-rendered glow
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 0d57ee5e79 gpu_parity: align the images by VRAM origin, and stop faking frame alignment
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen fcd577b02c scale_trace: always say HOW the numbers were taken; trust adjacent frames
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen fdf8a76274 scale_trace: actually apply the aliasing guard (the last commit did not)
The previous commit claimed two fixes and shipped one. Its patch script hit a
failed assertion on the third replacement and exited BEFORE writing the file,
so the two edits it had already made in memory were discarded — the same shape
as the probe fill that was written in memory and never saved. The commit
message described work that was not in the tree, which is worse than not doing
it, and only the tests I wrote alongside caught it.

Both edits are now in:

  * every per-frame claim checks native_sampling, so "these steps are the
    animation increment" cannot be printed over data the fallback produced
  * a fallback run gets its own granularity label saying outright that its
    steps are aliased and compare nothing

The reported document had native_sampling "free-running-fallback" AND
granularity "native-measured-per-frame" at once. That combination is now
unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 498a0fe45f scale_trace: never call aliased data an animation increment
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 1def9faeae packet_writers: hand the emulator back on EVERY path
"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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen b609424177 gpu_parity: wait for the effect, because the image is the untested half
Pooling every scale observation taken so far:

  psx-runtime  28 36 42 48 60 64 66 68 72 94 128   (28..128)
  oracle       52 64 68 72 76 96 112 116 ... 128   (52..128)

The ranges overlap almost completely, and every sample is aliased. The scale
data cannot diagnose anything, and saying otherwise would be reading a pattern
into noise for the third time.

What the accumulated evidence does say is narrower. Both display lists carry
the SAME subtractive primitives — PolyF4+semi|B-F x184 and PolyG3+semi|B-F x176
on each side — and the flat ones were byte-identical at (64,64,64). Subtractive
blending is how the outer darkening is drawn, and "the vignette is missing" is
half the reported symptom. So the guest is submitting the darkening on both
sides.

Geometry identical, GTE unused, translation clean over 5.7M blocks, base colour
table identical at rest, subtractive primitives present and matching. Every
guest-side explanation has been measured and ruled out, and the one thing never
tested at this effect is the IMAGE — because the oracle could not reach the
scene until the memory card was shared.

So gpu_parity gains the same wait every other tool has, on both sides. An image
diff of a scene without the effect answers a question nobody asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 1c1281e455 wait_for_class: make the wait affordable, and un-park before polling
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen ea41b4d28a scale_trace: fall back to the probe that works, and stop misreporting the scene
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 0f70aacccc tools: wait for the effect instead of racing it
"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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 7fc10ffa67 scale_trace: sweep across frames, and stop needing the oracle for this question
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 1a567d21ce scale_trace: sample both sides at once, and step before reading
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 13b86d8853 scale_trace: sample CONSECUTIVE frames, because the steps were aliasing
psx-runtime finally produced a real series — 7 samples, 48/94/128, apparent
steps of 40-46 — against the oracle's 122/124/126/128 stepping by 2. Twenty
times coarser, and it would explain hard edges instead of a gradient.

It is not evidence. Both series are sampled about 1.5s apart, roughly ninety
frames, so the difference between consecutive readings is aliasing rather than
the animation increment. A fade stepping by 2 every frame, sampled every ninety
frames, produces arbitrary-looking jumps too — which is exactly what 48/94/128
could be.

--per-frame steps ONE frame between reads, so the deltas ARE the increment.
That needs one emulator rather than two, which is the property that has made
every successful measurement in this investigation work: no frame alignment, no
matching buffer half, no shared phase. If psx-runtime's per-frame step is 2-4
the fade is smooth and the fault is elsewhere; if it is 40 the fade is coarse
and that is the picture.

The oracle's series stays non-frame-adjacent, so its apparent step is an upper
bound only, and the report says so rather than comparing the two as if they
were measured the same way.

Also fixes the Studio passing a bare "--gap" as the off case for a boolean
flag, which would have failed in argparse before the tool ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 5de4822b6e scale_trace: a verdict needs a series; caching must not replace the search
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 72ceb274e8 scale_trace: compare HOW each side animates, and stop resampling the search
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>
2026-08-24 12:55:58 -04:00
Alex Vanderveen 2cd0a93c9a probe: sweep for the block leader instead of guessing one window
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>
2026-08-24 12:55:58 -04:00