Skip to content

Commit 390848e

Browse files
doublegateclaude
andauthored
feat(accuracysnes): cover C11.03, and retract the interlace-gate claim (T-04-H) (#302)
C11.03 -- each M7x * ORG product is masked to a multiple of 64 before the terms accumulate -- is now covered by a Mode 7 scene with M7A and M7B both $0101, deliberately not round numbers. With M7B = $0101 the discarded part is `line MOD 64`, a different amount on every line, so a core that accumulates the full products samples a different texel on roughly a quarter of the columns of most lines. Every other Mode 7 scene uses round matrix values, which hide this completely -- none of them was evidence for the row. Blessed at 0xc032679c9076440a, which all three references produce, stable across repeated runs. Non-vacuity confirmed by removing the mask from fetch_mode7_column: this scene's hash moves. Two existing scenes move under that injection too, so the mask was already witnessed in aggregate -- what this adds is a scene whose stated purpose is the mask. RETRACTION. PR #299 claimed the field gate turned C7.12's three-way emulator split into a two-way one, citing snes9x and Mesen2 producing the identical hash. That was ONE RUN and it does not hold: the identical ROM run twice under Mesen2 gives 0x16f7ab8c7f97b7f8 then 0x266241e43ab85064, the two field parities alternating run to run. RustySNES and snes9x are each stable; Mesen2 is not. The gate is necessary but not sufficient and the missing half is host-side: the cart publishes only on its chosen field, but which RENDERED frame a host associates with the R_SCENE value it read is not pinned, and Mesen2's headless runner does not make that association deterministically. The interlace scene is withdrawn rather than left unblessed, because a scene reporting a different hash each run is noise in the gate output. The gate itself stays -- cart-side half, moves no golden, costs only battery runtime. Coverage is unchanged at 350 of 443: C11.03 replaces C7.12 one-for-one, because the withdrawn scene was being counted as C7.12's scene-cover while still unblessed. What changed is that the row now counted has a golden all three references agree on. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ba1ea3a commit 390848e

9 files changed

Lines changed: 162 additions & 119 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Added
1313

14+
- **The H-IRQ comparator moves into the clock domain (`T-06-A`), and nothing below the long dots
15+
moves with it.** `HIRQ_TRIGGER_DELAY = 4` was a *dot-domain rounding* of ares'
16+
`hcounter(10) == (HTIME+1)<<2` — exact only while every dot is four clocks, which stopped being
17+
true when dots 323 and 327 became six. The match is now computed where it actually happens:
18+
clock `4·HTIME + 14` (`hirq_match_clock`), mapped to the first dot boundary at or after it
19+
(`hirq_trigger_dot`).
20+
21+
**Below the long dots the two agree exactly**, because `4·HTIME + 14` is never a multiple of 4 and
22+
the next boundary is `HTIME + 4`. They diverge only for `HTIME` **321..=337**, where the six-clock
23+
dots have displaced every later boundary — the old constant fired up to a whole dot late, and at
24+
`HTIME = 336` suppressed an IRQ that does fire. `HTIME = 337` lands on dot 340's boundary, which
25+
exists only on the long line, so it is honoured there and suppressed elsewhere.
26+
27+
This is the change the plan recorded as *"attempted and reverted because it moves
28+
`hdmaen_latch_test_2`'s golden"*. It does not, this time: **no framebuffer golden moved**, the
29+
undisbeliever suite passes unchanged, and cross-validation is byte-identical
30+
(`snes9x: OK (14 known)`, `Mesen2: OK (2 known)`).
31+
32+
**`B4.16` is a weaker guard than its own doc claimed, and that is worth knowing.** Measured either
33+
side of the change, *both* of its readings are unchanged — including the `HTIME = 330` one, whose
34+
trigger dot moved 334 → 333. The CPU takes an IRQ at an instruction boundary, so the handler-entry
35+
dot quantises to the spin loop's instruction length and a one-dot shift is absorbed. `B4.16` can
36+
say "nothing regressed"; it cannot say "the change took effect". A unit test does that, sweeping
37+
every `HTIME` and asserting equality with the old constant below the long dots and strict
38+
inequality above them.
39+
40+
`LONG_DOTS` and the per-dot clock count also move to `rustysnes-ppu`, which owns the dot model and
41+
now needs the same layout twice. `rustysnes-core`'s scheduler delegates to it rather than keeping
42+
a second copy.
43+
44+
- **AccuracySNES: the scene protocol publishes on a known field.** `run_scenes` now sets the scene
45+
ID only on frames whose `$213F` bit 7 is set, so every sighting the host counts is the same
46+
*cart-side* field. `SCENE_FRAMES` grew 8 → 12: at half the publication rate the old value put the
47+
host's fourth sighting on the window's *last* frame, one of the two ends the capture protocol
48+
exists to avoid. The 53 blessed scenes are unaffected on all three hosts — a still picture hashes
49+
the same on either field.
50+
51+
**RETRACTION.** This entry first claimed the gate had turned `C7.12`'s three-way emulator split
52+
into a two-way one, citing snes9x and Mesen2 producing the identical hash. **That was one run, and
53+
it does not hold.** Running the *identical ROM* twice under Mesen2 gives `0x16f7ab8c7f97b7f8` then
54+
`0x266241e43ab85064` — exactly the two field-parity outcomes, alternating run to run. RustySNES and
55+
snes9x are each stable; Mesen2 is not.
56+
57+
**The gate is necessary but not sufficient, and the missing half is host-side.** The cart is
58+
deterministic: it publishes only on its chosen field. What is not pinned is which *rendered frame*
59+
a host associates with the `R_SCENE` value it read — Mesen2's headless runner does not make that
60+
association deterministically. The interlace scene was therefore **withdrawn**, not left
61+
unblessed: a scene reporting a different hash each run is noise in the gate output. The gate stays
62+
— it is the cart-side half, it moves no golden, and it costs only battery runtime — but nothing
63+
exploits it until a host can pin the frame it hashes. `C9.03`/`C9.06` are not written on top of
64+
it for the same reason.
65+
66+
The lesson is `E8.01`'s, again: a result that depends on a phase nobody controls looks stable
67+
until you run it twice.
68+
69+
- **AccuracySNES: `C11.03` covered — each `M7x * ORG` product is masked to a multiple of 64 before
70+
accumulation.** A new Mode 7 scene with `M7A` and `M7B` both `$0101`, deliberately not round
71+
numbers: with `M7B = $0101` the discarded part is `line MOD 64`, a different amount on every line,
72+
so a core that accumulates the full products samples a different texel on roughly a quarter of the
73+
columns of most lines. Every other Mode 7 scene uses round matrix values, which hide this
74+
completely — none of them was evidence for the row.
75+
76+
**The coverage total does not move**, and the regenerated report is why: `C11.03` replaces `C7.12`
77+
one-for-one, because the withdrawn interlace scene was being counted as scene-cover for `C7.12`
78+
while it was still unblessed. 350 of 443 either way — what changed is that the row now counted is
79+
one whose golden all three references agree on.
80+
81+
Blessed at `0xc032679c9076440a`, which **all three** references produce, and non-vacuity confirmed
82+
by removing the mask from `fetch_mode7_column`: this scene's hash moves. Worth recording that two
83+
existing scenes (`c11-mode7-rotate-scale`, `c11-mode7-window`) also move under that injection, so
84+
the mask was already witnessed in aggregate — what this adds is a scene whose stated purpose is
85+
the mask, which is what makes the coverage claim mean something.
86+
1487
- **The H-IRQ comparator moves into the clock domain (`T-06-A`), and nothing below the long dots
1588
moves with it.** `HIRQ_TRIGGER_DELAY = 4` was a *dot-domain rounding* of ares'
1689
`hcounter(10) == (HTIME+1)<<2` — exact only while every dot is four clocks, which stopped being

‎docs/accuracysnes-coverage.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ Declared in `gen/src/scenes.rs`. Each is reported by the host framebuffer oracle
130130
- **`C7.03`** — c7-hflip-sliver-order
131131
- **`C8.12`** — c8-force-black-outside-window
132132
- **`C5.14`** — c5-4bpp-bitplane-order
133-
- **`C7.12`** — c7-obj-interlace-halves-height
133+
- **`C11.03`** — c11-mode7-product-low-bits-masked
134134

135135
## Tests with no enumerated assertion
136136

‎docs/accuracysnes-plan.md‎

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ AccuracySNES closed ticket **T-04**. The follow-on tickets minted here are **T-0
1515
|---|---|
1616
| Tests | **338** (scoring + golden vectors + region SKIP per image) — *tests, not assertions; see the note below the table* |
1717
| Assertion coverage | **350 of 443** dossier assertions — **297 on-cart** + **53 rendered scenes**, kept as separate columns (`docs/accuracysnes-coverage.md`) |
18-
| Rendered scenes | **53**, all cross-validated (`docs/adr/0013`) |
18+
| Rendered scenes | **54** declared and all cross-validated (`docs/adr/0013`); **53** are the dossier rows they are the only cover for |
1919
| Pass rate | **100.00%** on-cart, floor enforced at 1.00 by `tests/accuracysnes.rs` |
2020
| Cross-validated | RustySNES and Mesen2 agree on every test but two `PAD2_CONTRACT` rows the Lua runner cannot drive; snes9x agrees on every test but a handful of recorded reference bugs with citations in `scripts/accuracysnes/crossval.sh`; a headless **MesenCE** (this cycle) is the per-dot compositor's blueprint + exact-frame oracle. All images. |
2121
| Groups shipped | **A** (65C816) · **B** (5A22) · **C** (PPU, on-cart and rendered) · **D** (DMA/HDMA) · **E** (SPC700 + S-DSP) · **F** (controller ports) · **G** (cartridge/memory map) — all seven, all partial |
@@ -430,39 +430,44 @@ Any `C9`/`C7.12` interlace assertion needs the cart to publish a scene only on a
430430
a change to `run_scenes`, not to a scene. Worth doing: it unblocks the interlace half of `C9`
431431
(`C9.03`, `C9.06`) as well as `C7.12`.
432432

433-
**BUILT 2026-08-01, and it did what it was supposed to — but the scene is still unblessable.**
433+
**BUILT 2026-08-01 — and then measured properly, which changed the conclusion.**
434434
`run_scenes` now publishes the scene ID only on frames whose `$213F` bit 7 is set, so every sighting
435-
the host counts is the same field (`SCENE_FRAMES` grew 8 → 12, because at half the publication rate
436-
the old value put the host's fourth sighting on the window's last frame — one of the two ends the
437-
protocol exists to avoid). The 53 blessed scenes are untouched by it on all three hosts: a still
438-
picture hashes the same on either field.
435+
the host counts is the same *cart-side* field (`SCENE_FRAMES` grew 8 → 12, because at half the
436+
publication rate the old value put the host's fourth sighting on the window's last frame — one of
437+
the two ends the protocol exists to avoid). The 53 blessed scenes are untouched by it on all three
438+
hosts: a still picture hashes the same on either field.
439439

440-
On the interlace scene it worked exactly as intended. The **three**-way split became a **two**-way
441-
one, and snes9x and Mesen2 now produce the *identical* hash:
440+
**The first reading of the result was wrong, and it was wrong because it was a single run.** An OBJ
441+
interlace scene was added, the three-way hash split became a two-way one — snes9x and Mesen2
442+
produced the *identical* `0x266241e43ab85064` — and that was published as "the gate works". It does
443+
not hold. Running the **identical ROM** twice under Mesen2 gives:
442444

443-
| | `c7-obj-interlace-halves-height` |
445+
| run | `c7-obj-interlace-halves-height` under Mesen2 |
444446
|---|---|
445-
| snes9x | `0x266241e43ab85064` |
446-
| Mesen2 | `0x266241e43ab85064` |
447-
| RustySNES | `0x16f7ab8c7f97b7f8` |
448-
449-
**And the residual is fully characterised**, which is the part worth keeping. Dumping the pixels
450-
shows both renders agree on everything the row is *about* — the 16x32 sprite occupies 16 display
451-
rows, its 32x64 neighbour 32 — and differ only in **which field's source rows** are drawn:
452-
snes9x/Mesen2 draw the even ones, RustySNES the odd ones. One source row, nothing else.
453-
454-
**Not blessed, and not called a RustySNES bug either.** "RustySNES alone" is this project's
455-
signature for a real defect, but RustySNES is not alone here: its `row + field` and its
456-
`ppu2.mdr.bit(7) = field()` are both ares' (`sfc/ppu/object.cpp:122`, `sfc/ppu/io.cpp:178`), and both
457-
cores toggle the field at the same V wrap. So this is the bsnes/ares lineage against snes9x and
458-
Mesen2 — **2 vs 1** by this project's own provenance rule, not 2 vs 2, and no primary source says
459-
which field maps to which rows. Recorded as a variant set per the same rule the Mode-5 hi-res
460-
cluster is under: *do not pick a winner*. The tiebreaker is ares actually running the cart, which is
461-
the same blocked item `A2.10` is waiting on.
447+
| 1 | `0x16f7ab8c7f97b7f8` |
448+
| 2 | `0x266241e43ab85064` |
449+
450+
Those are exactly the two field-parity outcomes, and Mesen2 alternates between them **run to run on
451+
one build**. RustySNES and snes9x are each stable; Mesen2 is not.
452+
453+
**So the gate is necessary but NOT sufficient, and the missing half is host-side.** The cart's own
454+
behaviour is deterministic — it publishes only on its chosen field. What is not pinned is which
455+
*rendered frame* a host associates with the `R_SCENE` value it read: if the host's frame boundary
456+
sits a frame away from the cart's vblank, it hashes frame N while the cart published on frame N−1's
457+
field. Mesen2's headless runner does not make that association deterministically.
458+
459+
**The scene was therefore withdrawn**, not merely left unblessed: a scene that reports a different
460+
hash on each run is noise in the gate output, and its "the references agree" reading was an artifact
461+
of looking once. The gate itself is kept — it is the cart-side half of the fix, it costs only
462+
battery runtime, and it moves no golden — but nothing exploits it until a host can pin the frame it
463+
hashes.
464+
465+
**The lesson, and it is the same one `E8.01` taught in the same session:** a result that depends on a
466+
phase nobody controls looks stable until you run it twice. Run an interlace or field-dependent
467+
capture **at least twice per host** before believing any part of it.
462468

463469
`C9.03`/`C9.06` are deliberately NOT written on top of this. Screen interlace has the identical
464-
parity dependency, so they would land unblessed for the identical reason — two more unblessable
465-
scenes is not progress. Write them when the tiebreaker exists.
470+
parity dependency, so they would land in the identical state. Write them when the tiebreaker exists.
466471

467472
### `E8.03` — "clears `ENDX` even when suppressed" does not mean suppressed by `KOFF`
468473

‎tests/golden/accuracysnes-scenes.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,4 @@ c7-name-select-picks-second-table 0x11e9afd7545c0b25
201201
c7-hflip-sliver-order 0x863f085bccebd107
202202
c8-force-black-outside-window 0x21a5b8a828e748d7
203203
c5-4bpp-bitplane-order 0xf11def7ad73be325
204+
c11-mode7-product-low-bits-masked 0xc032679c9076440a

‎tests/roms/AccuracySNES/asm/scenes.s‎

Lines changed: 27 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,55 +2247,38 @@ SCENES_IMPL = 1
22472247
rts
22482248
.endproc
22492249

2250-
; c7-obj-interlace-halves-height — C7.12
2251-
; `c7-objsel-size-6` with OBJ interlace on (SETINI bit 1) and NOTHING else changed. Under interlace a sprite's displayed line maps to twice its internal row, so the 16x32 small member and the 32x64 large one each occupy half the vertical space they otherwise would — a core that ignores SETINI bit 1 renders this scene identically to its twin, which is exactly the comparison the pair exists to make. THIS SCENE IS FIELD-DEPENDENT: interlace selects even or odd sprite rows by field parity, which is why `run_scenes` gates the published window on the field flag. It was written once WITHOUT that gate and produced three different hashes on three emulators, the only three-way split any scene has produced.
2252-
.proc scene_c7_obj_interlace_halves_height
2250+
; c11-mode7-product-low-bits-masked — C11.03
2251+
; Mode 7 with `M7A` and `M7B` both `$0101` — deliberately NOT round numbers. Each `M7x * ORG` product has its low six bits masked off (`AND NOT $3F`) before the terms are accumulated, so the per-line origin is quantised to a multiple of 64 in the 1/256-texel fixed point. With `M7B = $0101` the discarded part is `line MOD 64`, which is a different amount on every line — a core that accumulates the full products samples a different texel on roughly a quarter of the columns of most lines, and the two pictures are nothing alike. Round matrix values hide this completely, which is why every other Mode 7 scene here uses them and none of them is evidence for this row.
2252+
.proc scene_c11_mode7_product_low_bits_masked
22532253
.a16
22542254
.i16
22552255
sep #$20
22562256
.a8
2257-
stz $2105 ; BGMODE 0
2258-
jsr scene_oam_reset
2259-
sep #$20
2260-
.a8
2261-
lda #$C0
2262-
sta $2101 ; OBJSEL pair 6, name base word $0000 — same as the twin
2263-
rep #$30
2264-
.a16
2265-
.i16
2266-
ldx #$0000
2267-
stx $2102
2268-
sep #$20
2269-
.a8
2270-
lda #40
2271-
sta $2104 ; sprite 0 X
2272-
lda #60
2273-
sta $2104 ; sprite 0 Y
2274-
lda #$10
2275-
sta $2104 ; tile $10 — printable at 4bpp
2276-
lda #$30
2277-
sta $2104 ; attr: palette 0, priority 3
2278-
lda #120
2279-
sta $2104 ; sprite 1 X
2280-
lda #60
2281-
sta $2104 ; sprite 1 Y
2282-
lda #$10
2283-
sta $2104
2284-
lda #$30
2285-
sta $2104
2286-
rep #$30
2287-
.a16
2288-
.i16
2289-
ldx #$0100
2290-
stx $2102 ; the high table
2257+
lda #$07
2258+
sta $2105 ; BGMODE 7
2259+
jsr scene_mode7_vram
22912260
sep #$20
22922261
.a8
2293-
lda #$08
2294-
sta $2104 ; sprite 0 small (16x32), sprite 1 large (32x64)
2295-
lda #$02
2296-
sta $2133 ; SETINI bit 1: OBJ interlace — the ONE difference from the twin
2297-
lda #$10
2298-
sta $212C
2262+
stz $211A ; M7SEL: no repeat, no flips
2263+
lda #$01
2264+
sta $211B
2265+
sta $211B ; M7A = $0101 — the low byte is what makes the mask observable
2266+
sta $211C
2267+
sta $211C ; M7B = $0101, so the discarded part is line MOD 64
2268+
stz $211D
2269+
stz $211D ; M7C = 0
2270+
stz $211E
2271+
sta $211E ; M7D = $0100
2272+
stz $211F
2273+
stz $211F ; M7X = 0
2274+
stz $2120
2275+
stz $2120 ; M7Y = 0
2276+
stz $210D
2277+
stz $210D ; M7HOFS = 0
2278+
stz $210E
2279+
stz $210E ; M7VOFS = 0
2280+
lda #$01
2281+
sta $212C ; BG1 on the main screen
22992282
lda #$0F
23002283
sta $2100
23012284
rep #$30
@@ -2363,4 +2346,4 @@ _scene_entries:
23632346
.addr scene_c7_hflip_sliver_order
23642347
.addr scene_c8_force_black_outside_window
23652348
.addr scene_c5_4bpp_bitplane_order
2366-
.addr scene_c7_obj_interlace_halves_height
2349+
.addr scene_c11_mode7_product_low_bits_masked
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.

‎tests/roms/AccuracySNES/build/scenes.tsv‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@
5252
51 c7-hflip-sliver-order C7.03
5353
52 c8-force-black-outside-window C8.12
5454
53 c5-4bpp-bitplane-order C5.14
55-
54 c7-obj-interlace-halves-height C7.12
55+
54 c11-mode7-product-low-bits-masked C11.03

0 commit comments

Comments
 (0)