diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5e8bd0..477a84e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **AccuracySNES: the scene protocol publishes on a known field, and the interlace three-way split + is down to a two-way one.** `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 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, + which is 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, so gating only moves which wall-clock frame the fourth sighting is. + + **On the interlace scene it did exactly what it was built to do.** `c7-obj-interlace-halves-height` + (`C7.12`, a 16x32 sprite under OBJ interlace, identical to `c7-objsel-size-6` but for one `SETINI` + bit) previously produced **three** different hashes on three emulators — the only three-way split + any scene has produced, and not a disagreement about interlace at all: each host landed on + whichever field its own frame counter happened to be on. With the gate, snes9x and Mesen2 produce + the **identical** hash `0x266241e43ab85064`. + + **The residual is one source row, and it is not blessed.** Pixel dumps show both renders agree on + everything the row asserts — the 16x32 sprite occupies 16 display rows — and differ only in which + field's source rows are drawn: the references take the even ones, RustySNES the odd. That is not + "RustySNES alone", this project's signature for a real defect: RustySNES's `row + field` and its + `$213F` bit 7 are both ares' (`sfc/ppu/object.cpp:122`, `sfc/ppu/io.cpp:178`), and both toggle the + field at the same V wrap. So it is the bsnes/ares lineage against the other two — **2 vs 1** under + this project's own provenance rule — and no primary source says which field maps to which rows. + Recorded as a variant set; the tiebreaker is ares running the cart, the same blocked item `A2.10` + waits on. `C9.03`/`C9.06` are deliberately not written on top of it: screen interlace carries the + identical parity dependency, so they would land unblessed for the identical reason. + - **AccuracySNES: three Group E rows land — `E8.01`, `E9.02`, `E5.06`.** Coverage **347 → 350 of 443** (297 on-cart + 53 scenes), battery **338 tests at 100%**, and both references agree: `snes9x: OK (14 known divergence(s))`, `Mesen2: OK (2 known divergence(s))`, 53/53 scenes on each. diff --git a/docs/accuracysnes-coverage.md b/docs/accuracysnes-coverage.md index b457d042..115f2601 100644 --- a/docs/accuracysnes-coverage.md +++ b/docs/accuracysnes-coverage.md @@ -130,6 +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 ## Tests with no enumerated assertion diff --git a/docs/accuracysnes-plan.md b/docs/accuracysnes-plan.md index 3d95c9f9..c7b20615 100644 --- a/docs/accuracysnes-plan.md +++ b/docs/accuracysnes-plan.md @@ -389,6 +389,40 @@ 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.** +`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. + +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: + +| | `c7-obj-interlace-halves-height` | +|---|---| +| 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. + +`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. + ### `E8.03` — "clears `ENDX` even when suppressed" does not mean suppressed by `KOFF` The dossier row reads: *"KON restarts even if playing, zeroing the envelope, and clears ENDX even @@ -929,10 +963,12 @@ documentation-anchored only, because the Mesen2 headless runner times out in thi `C7.05`/`C7.06` landed on the far-IRQ shim, so it is worth saying what that shim does **not** unblock. -- **`C7.12`** (16x32 under OBJ interlace renders as 16x16) is a **scene**, and needs the cart to - publish a scene only on a *known field* — it was written once and produced three different hashes - on three emulators because it asked a question whose answer alternates every frame. That is - `v1.29.0` machinery (`run_scenes`, not a scene), shared with `C9.03`/`C9.06`. +- **`C7.12`** (16x32 under OBJ interlace renders as 16x16) is a **scene**. The `v1.29.0` machinery + it needed — `run_scenes` publishing only on a known field — **is built** (2026-08-01), and it + turned the three-way split into a two-way one: snes9x and Mesen2 now agree exactly, and RustySNES + differs by one field of OBJ-interlace source rows and nothing else. Still unblessed, because that + residual is the ares lineage against the other two and no source settles it. Full account in + §"Interlace scenes need frame-parity control". - **`C7.07`** (Time Over false positive: first sprite 16x16+ at `X = 0-255` with others at negative X) is reachable *as a test* with `C7.06`'s setup, but it would fail — because RustySNES does not diff --git a/tests/roms/AccuracySNES/asm/runtime.inc b/tests/roms/AccuracySNES/asm/runtime.inc index 87ec4cda..04e77547 100644 --- a/tests/roms/AccuracySNES/asm/runtime.inc +++ b/tests/roms/AccuracySNES/asm/runtime.inc @@ -139,6 +139,7 @@ V_HW_H = VAR_BASE + $4A ; H from the most recent wide latch (u16) V_HW_V = VAR_BASE + $4C ; V from that same latch (u16) V_HW_V0 = VAR_BASE + $4E ; V at hv_begin_wide (u16) V_HW_DV = VAR_BASE + $50 ; scratch: lines crossed, counted down (u16) +V_SCENE_ID = VAR_BASE + $52 ; the scene ID `run_scenes` publishes on its field (u16) ; The longest span hv_end_wide will report. Beyond this the varying line length has accumulated past ; usefulness, and far beyond it V wraps the field and the delta goes negative — so both are refused ; with $FFFF rather than returned as a plausible number. 64 lines is ~21,800 dots, two orders of @@ -222,7 +223,12 @@ MEAS_SLOTS = 512 ; occasionally landed on a transition and the gate went red on a different scene each run. An ; intermittently-red gate is worse than a slow one, because it gets ignored. SCENE_SETTLE = 6 -SCENE_FRAMES = 8 +; 12, not 8, because the published window is FIELD-GATED (see `run_scenes`): the ID appears on +; alternate frames only, so 8 wall-clock frames give exactly four sightings and the host's fourth — +; the one it hashes — lands on the window's last frame. That is one of the two ends the host +; protocol exists to avoid; snes9x caught a scene mid-transition there once already. 12 frames give +; six sightings, so the fourth is comfortably interior. +SCENE_FRAMES = 12 ; --- results block --------------------------------------------------------------------------- RESULTS = $7EF000 diff --git a/tests/roms/AccuracySNES/asm/runtime.s b/tests/roms/AccuracySNES/asm/runtime.s index b93a6bdd..ced90776 100644 --- a/tests/roms/AccuracySNES/asm/runtime.s +++ b/tests/roms/AccuracySNES/asm/runtime.s @@ -2086,18 +2086,54 @@ test_restore := test_restore_impl jsr hold_frames plx - sep #$20 - .a8 - txa - inc a - sta f:R_SCENE ; scene IDs are 1-based; 0 means "none yet" - + ; --- The published window is FIELD-GATED ------------------------------------------------- + ; + ; The ID appears only on frames whose PPU2 field flag ($213F bit 7) is SET, so every sighting + ; the host counts is the same field. Without that, an interlace scene asks a question whose + ; answer alternates every frame: `C7.12` (a 16x32 sprite under OBJ interlace renders as 16x16) + ; was written as a scene and produced THREE different hashes on three emulators, the only + ; three-way split any scene has produced. It was not three cores disagreeing about interlace — + ; each host simply landed on whichever field its own frame counter happened to be on. + ; + ; Costs one bit of nothing for the 53 non-interlaced scenes: a still picture hashes the same on + ; either field, so gating only moves WHICH wall-clock frame the host's fourth sighting is. That + ; is why SCENE_FRAMES had to grow — at half the publication rate, the old 8 gave exactly four + ; sightings and the fourth landed on the window's last frame, the one end the host protocol + ; deliberately avoids. + ; + ; What this does NOT fix, and cannot: if two hosts sample WRAM at different points relative to + ; the field toggle, they gate on opposite fields and an interlace scene still disagrees. That is + ; a measurable question, not an assumption — cross-validate any interlace scene before blessing + ; a golden for it (ADR 0013 rule 4). rep #$30 .a16 .i16 + txa + inc a + and #$00FF + sta f:V_SCENE_ID ; scene IDs are 1-based; 0 means "none yet" phx ldx #SCENE_FRAMES - jsr hold_frames +@hold_field: + phx + jsr wait_vblank + plx + sep #$20 + .a8 + lda $213F ; PPU2 status; bit 7 is the field flag + and #$80 + beq @not_our_field + lda f:V_SCENE_ID + bra @publish +@not_our_field: + lda #$00 +@publish: + sta f:R_SCENE + rep #$30 + .a16 + .i16 + dex + bne @hold_field plx sep #$20 diff --git a/tests/roms/AccuracySNES/asm/scenes.s b/tests/roms/AccuracySNES/asm/scenes.s index f17bee1f..3d92369e 100644 --- a/tests/roms/AccuracySNES/asm/scenes.s +++ b/tests/roms/AccuracySNES/asm/scenes.s @@ -2247,11 +2247,68 @@ SCENES_IMPL = 1 rts .endproc +; c7-obj-interlace-halves-height — C7.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 + .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 + 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 + lda #$0F + sta $2100 + rep #$30 + .a16 + .i16 + rts +.endproc + .segment "CATALOG" .export _scene_count .export _scene_entries _scene_count: - .word 53 + .word 54 _scene_entries: .addr scene_c5_mode1_bg_priority .addr scene_c8_fixed_colour_add @@ -2306,3 +2363,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 diff --git a/tests/roms/AccuracySNES/build/accuracysnes-pal.sfc b/tests/roms/AccuracySNES/build/accuracysnes-pal.sfc index d971aa52..8da1f043 100644 Binary files a/tests/roms/AccuracySNES/build/accuracysnes-pal.sfc and b/tests/roms/AccuracySNES/build/accuracysnes-pal.sfc differ diff --git a/tests/roms/AccuracySNES/build/accuracysnes.sfc b/tests/roms/AccuracySNES/build/accuracysnes.sfc index d4c27f52..0a8463d8 100644 Binary files a/tests/roms/AccuracySNES/build/accuracysnes.sfc and b/tests/roms/AccuracySNES/build/accuracysnes.sfc differ diff --git a/tests/roms/AccuracySNES/build/scenes.tsv b/tests/roms/AccuracySNES/build/scenes.tsv index 05959f7c..1a163acf 100644 --- a/tests/roms/AccuracySNES/build/scenes.tsv +++ b/tests/roms/AccuracySNES/build/scenes.tsv @@ -52,3 +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 diff --git a/tests/roms/AccuracySNES/gen/src/scenes.rs b/tests/roms/AccuracySNES/gen/src/scenes.rs index 7d77ad95..2857ea1d 100644 --- a/tests/roms/AccuracySNES/gen/src/scenes.rs +++ b/tests/roms/AccuracySNES/gen/src/scenes.rs @@ -1892,6 +1892,59 @@ pub const SCENES: &[Scene] = &[ "sta $2100 ; brightness 15, forced blank off", ], }, + Scene { + id: "c7-obj-interlace-halves-height", + dossier: "C7.12", + what: "`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.", + setup: &[ + "sep #$20", + "stz $2105 ; BGMODE 0", + "jsr scene_oam_reset", + "sep #$20", + "lda #$C0", + "sta $2101 ; OBJSEL pair 6, name base word $0000 — same as the twin", + "rep #$30", + "ldx #$0000", + "stx $2102", + "sep #$20", + "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", + "ldx #$0100", + "stx $2102 ; the high table", + "sep #$20", + "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", + "lda #$0F", + "sta $2100", + ], + }, ]; /// The comment block `scene_low_tiles` carries, split out only to keep `low_tiles_helper` inside