Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **The H-IRQ comparator moves into the clock domain (`T-06-A`), and nothing below the long dots
moves with it.** `HIRQ_TRIGGER_DELAY = 4` was a *dot-domain rounding* of ares'
`hcounter(10) == (HTIME+1)<<2` — exact only while every dot is four clocks, which stopped being
true when dots 323 and 327 became six. The match is now computed where it actually happens:
clock `4·HTIME + 14` (`hirq_match_clock`), mapped to the first dot boundary at or after it
(`hirq_trigger_dot`).

**Below the long dots the two agree exactly**, because `4·HTIME + 14` is never a multiple of 4 and
the next boundary is `HTIME + 4`. They diverge only for `HTIME` **321..=337**, where the six-clock
dots have displaced every later boundary — the old constant fired up to a whole dot late, and at
`HTIME = 336` suppressed an IRQ that does fire. `HTIME = 337` lands on dot 340's boundary, which
exists only on the long line, so it is honoured there and suppressed elsewhere.

This is the change the plan recorded as *"attempted and reverted because it moves
`hdmaen_latch_test_2`'s golden"*. It does not, this time: **no framebuffer golden moved**, the
undisbeliever suite passes unchanged, and cross-validation is byte-identical
(`snes9x: OK (14 known)`, `Mesen2: OK (2 known)`).

**`B4.16` is a weaker guard than its own doc claimed, and that is worth knowing.** Measured either
side of the change, *both* of its readings are unchanged — including the `HTIME = 330` one, whose
trigger dot moved 334 → 333. The CPU takes an IRQ at an instruction boundary, so the handler-entry
dot quantises to the spin loop's instruction length and a one-dot shift is absorbed. `B4.16` can
say "nothing regressed"; it cannot say "the change took effect". A unit test does that, sweeping
every `HTIME` and asserting equality with the old constant below the long dots and strict
inequality above them.

`LONG_DOTS` and the per-dot clock count also move to `rustysnes-ppu`, which owns the dot model and
now needs the same layout twice. `rustysnes-core`'s scheduler delegates to it rather than keeping
a second copy.

- **AccuracySNES: the scene protocol publishes on a known field.** `run_scenes` now sets the scene
ID only on frames whose `$213F` bit 7 is set, so every sighting the host counts is the same
*cart-side* field. `SCENE_FRAMES` grew 8 → 12: at half the publication rate the old value put the
host's fourth sighting on the window's *last* frame, one of the two ends the capture protocol
exists to avoid. The 53 blessed scenes are unaffected on all three hosts — a still picture hashes
the same on either field.
Comment on lines +44 to +49

**RETRACTION.** This entry first claimed the gate had 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.** Running the *identical ROM* twice under Mesen2 gives `0x16f7ab8c7f97b7f8` then
`0x266241e43ab85064` — exactly the two field-parity outcomes, 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 is
deterministic: it publishes only on its chosen field. What is not pinned is which *rendered frame*
a host associates with the `R_SCENE` value it read — Mesen2's headless runner does not make that
association deterministically. The interlace scene was therefore **withdrawn**, not left
unblessed: a scene reporting a different hash each run is noise in the gate output. The gate stays
— it is the cart-side half, it moves no golden, and it costs only battery runtime — but nothing
exploits it until a host can pin the frame it hashes. `C9.03`/`C9.06` are not written on top of
it for the same reason.

The lesson is `E8.01`'s, again: a result that depends on a phase nobody controls looks stable
until you run it twice.

- **AccuracySNES: `C11.03` covered — each `M7x * ORG` product is masked to a multiple of 64 before
accumulation.** A new 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.

**The coverage total does not move**, and the regenerated report is why: `C11.03` replaces `C7.12`
one-for-one, because the withdrawn interlace scene was being counted as scene-cover for `C7.12`
while it was still unblessed. 350 of 443 either way — what changed is that the row now counted is
one whose golden all three references agree on.

Blessed at `0xc032679c9076440a`, which **all three** references produce, and non-vacuity confirmed
by removing the mask from `fetch_mode7_column`: this scene's hash moves. Worth recording that two
existing scenes (`c11-mode7-rotate-scale`, `c11-mode7-window`) also move under that injection, so
the mask was already witnessed in aggregate — what this adds is a scene whose stated purpose is
the mask, which is what makes the coverage claim mean something.

- **The H-IRQ comparator moves into the clock domain (`T-06-A`), and nothing below the long dots
moves with it.** `HIRQ_TRIGGER_DELAY = 4` was a *dot-domain rounding* of ares'
`hcounter(10) == (HTIME+1)<<2` — exact only while every dot is four clocks, which stopped being
Expand Down
2 changes: 1 addition & 1 deletion docs/accuracysnes-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Declared in `gen/src/scenes.rs`. Each is reported by the host framebuffer oracle
- **`C7.03`** — c7-hflip-sliver-order
- **`C8.12`** — c8-force-black-outside-window
- **`C5.14`** — c5-4bpp-bitplane-order
- **`C7.12`** — c7-obj-interlace-halves-height
- **`C11.03`** — c11-mode7-product-low-bits-masked

## Tests with no enumerated assertion

Expand Down
61 changes: 33 additions & 28 deletions docs/accuracysnes-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ AccuracySNES closed ticket **T-04**. The follow-on tickets minted here are **T-0
|---|---|
| Tests | **338** (scoring + golden vectors + region SKIP per image) — *tests, not assertions; see the note below the table* |
| Assertion coverage | **350 of 443** dossier assertions — **297 on-cart** + **53 rendered scenes**, kept as separate columns (`docs/accuracysnes-coverage.md`) |
| Rendered scenes | **53**, all cross-validated (`docs/adr/0013`) |
| Rendered scenes | **54** declared and all cross-validated (`docs/adr/0013`); **53** are the dossier rows they are the only cover for |
Comment on lines 16 to +18
| Pass rate | **100.00%** on-cart, floor enforced at 1.00 by `tests/accuracysnes.rs` |
| 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. |
| 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 |
Expand Down Expand Up @@ -430,39 +430,44 @@ Any `C9`/`C7.12` interlace assertion needs the cart to publish a scene only on a
a change to `run_scenes`, not to a scene. Worth doing: it unblocks the interlace half of `C9`
(`C9.03`, `C9.06`) as well as `C7.12`.

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

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

| | `c7-obj-interlace-halves-height` |
| run | `c7-obj-interlace-halves-height` under Mesen2 |
|---|---|
| snes9x | `0x266241e43ab85064` |
| Mesen2 | `0x266241e43ab85064` |
| RustySNES | `0x16f7ab8c7f97b7f8` |

**And the residual is fully characterised**, which is the part worth keeping. Dumping the pixels
shows both renders agree on everything the row is *about* — the 16x32 sprite occupies 16 display
rows, its 32x64 neighbour 32 — and differ only in **which field's source rows** are drawn:
snes9x/Mesen2 draw the even ones, RustySNES the odd ones. One source row, nothing else.

**Not blessed, and not called a RustySNES bug either.** "RustySNES alone" is this project's
signature for a real defect, but RustySNES is not alone here: its `row + field` and its
`ppu2.mdr.bit(7) = field()` are both ares' (`sfc/ppu/object.cpp:122`, `sfc/ppu/io.cpp:178`), and both
cores toggle the field at the same V wrap. So this is the bsnes/ares lineage against snes9x and
Mesen2 — **2 vs 1** by this project's own provenance rule, not 2 vs 2, and no primary source says
which field maps to which rows. Recorded as a variant set per the same rule the Mode-5 hi-res
cluster is under: *do not pick a winner*. The tiebreaker is ares actually running the cart, which is
the same blocked item `A2.10` is waiting on.
| 1 | `0x16f7ab8c7f97b7f8` |
| 2 | `0x266241e43ab85064` |

Those are exactly the two field-parity outcomes, and Mesen2 alternates between them **run to run on
one build**. RustySNES and snes9x are each stable; Mesen2 is not.

**So the gate is necessary but NOT sufficient, and the missing half is host-side.** The cart's own
behaviour is deterministic — it publishes only on its chosen field. What is not pinned is which
*rendered frame* a host associates with the `R_SCENE` value it read: if the host's frame boundary
sits a frame away from the cart's vblank, it hashes frame N while the cart published on frame N−1's
field. Mesen2's headless runner does not make that association deterministically.

**The scene was therefore withdrawn**, not merely left unblessed: a scene that reports a different
hash on each run is noise in the gate output, and its "the references agree" reading was an artifact
of looking once. The gate itself is kept — it is the cart-side half of the fix, it costs only
battery runtime, and it moves no golden — but nothing exploits it until a host can pin the frame it
hashes.

**The lesson, and it is the same one `E8.01` taught in the same session:** a result that depends on a
phase nobody controls looks stable until you run it twice. Run an interlace or field-dependent
capture **at least twice per host** before believing any part of it.

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

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

Expand Down
1 change: 1 addition & 0 deletions tests/golden/accuracysnes-scenes.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,4 @@ c7-name-select-picks-second-table 0x11e9afd7545c0b25
c7-hflip-sliver-order 0x863f085bccebd107
c8-force-black-outside-window 0x21a5b8a828e748d7
c5-4bpp-bitplane-order 0xf11def7ad73be325
c11-mode7-product-low-bits-masked 0xc032679c9076440a
71 changes: 27 additions & 44 deletions tests/roms/AccuracySNES/asm/scenes.s
Original file line number Diff line number Diff line change
Expand Up @@ -2247,55 +2247,38 @@ SCENES_IMPL = 1
rts
.endproc

; c7-obj-interlace-halves-heightC7.12
; `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.
.proc scene_c7_obj_interlace_halves_height
; c11-mode7-product-low-bits-maskedC11.03
; 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.
.proc scene_c11_mode7_product_low_bits_masked
.a16
.i16
sep #$20
.a8
stz $2105 ; BGMODE 0
jsr scene_oam_reset
sep #$20
.a8
lda #$C0
sta $2101 ; OBJSEL pair 6, name base word $0000 — same as the twin
rep #$30
.a16
.i16
ldx #$0000
stx $2102
sep #$20
.a8
lda #40
sta $2104 ; sprite 0 X
lda #60
sta $2104 ; sprite 0 Y
lda #$10
sta $2104 ; tile $10 — printable at 4bpp
lda #$30
sta $2104 ; attr: palette 0, priority 3
lda #120
sta $2104 ; sprite 1 X
lda #60
sta $2104 ; sprite 1 Y
lda #$10
sta $2104
lda #$30
sta $2104
rep #$30
.a16
.i16
ldx #$0100
stx $2102 ; the high table
lda #$07
sta $2105 ; BGMODE 7
jsr scene_mode7_vram
sep #$20
.a8
lda #$08
sta $2104 ; sprite 0 small (16x32), sprite 1 large (32x64)
lda #$02
sta $2133 ; SETINI bit 1: OBJ interlace — the ONE difference from the twin
lda #$10
sta $212C
stz $211A ; M7SEL: no repeat, no flips
lda #$01
sta $211B
sta $211B ; M7A = $0101 — the low byte is what makes the mask observable
sta $211C
sta $211C ; M7B = $0101, so the discarded part is line MOD 64
stz $211D
stz $211D ; M7C = 0
stz $211E
sta $211E ; M7D = $0100
stz $211F
stz $211F ; M7X = 0
stz $2120
stz $2120 ; M7Y = 0
stz $210D
stz $210D ; M7HOFS = 0
stz $210E
stz $210E ; M7VOFS = 0
lda #$01
sta $212C ; BG1 on the main screen
lda #$0F
sta $2100
rep #$30
Expand Down Expand Up @@ -2363,4 +2346,4 @@ _scene_entries:
.addr scene_c7_hflip_sliver_order
.addr scene_c8_force_black_outside_window
.addr scene_c5_4bpp_bitplane_order
.addr scene_c7_obj_interlace_halves_height
.addr scene_c11_mode7_product_low_bits_masked
Binary file modified tests/roms/AccuracySNES/build/accuracysnes-pal.sfc
Binary file not shown.
Binary file modified tests/roms/AccuracySNES/build/accuracysnes.sfc
Binary file not shown.
2 changes: 1 addition & 1 deletion tests/roms/AccuracySNES/build/scenes.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@
51 c7-hflip-sliver-order C7.03
52 c8-force-black-outside-window C8.12
53 c5-4bpp-bitplane-order C5.14
54 c7-obj-interlace-halves-height C7.12
54 c11-mode7-product-low-bits-masked C11.03
Loading
Loading