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
97 changes: 78 additions & 19 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`E2.10` — the full 256-opcode SPC700 cycle sweep, and the coverage number moves to 360 of 443.**
- **`A6.15` — every 65C816 opcode is defined, and only `STP` hangs. Coverage 361 of 443.** The row
executes each of the 241 straight-line opcodes in a WRAM sandbox and counts three outcomes against
the length **Table 5-4 of the WDC W65C816S datasheet** documents: returned where it should,
returned late, or did not return.

**The sandbox terminator cannot be a return.** `TXS` and `TCS` move the stack pointer — with
`x = 1` a `TXS` puts it in page zero — so the return address is no longer where an `RTS` would pop
it from. Control comes back through a `JMP`, and because a `JMP` is three bytes, the addresses are
chosen so its **own operand bytes are harmless one-byte instructions**: `$AAAA` (`TAX`) for the
clean exit and `$B8B8` (`CLV`) for the overshoot one. An opcode that consumes one byte too many
therefore executes a register transfer and walks into a `NOP` fill, instead of executing half an
address.

**The watchdog takes two strikes.** `runtime.s` already carried an NMI trampoline with a settable
vector; one strike would be wrong, because NMI fires once per vblank and across 241 sandbox runs
it will eventually land inside a *healthy* one.

**Four opcodes are dangerous even when correct, and the preamble handles each rather than
excluding it:** `MVN`/`MVP` move `A + 1` bytes, so `A = 0`; `XCE` flips to emulation mode only if
`C` is set, so `CLC` first makes it a no-op; `TXS`/`TCS` are why the exits restore `SP` from WRAM;
`SED` and `PLP` are why they re-establish `m`, `x`, `d` and `c` rather than trusting what came back.

**Verified by injection, twice.** `WDM` made a three-byte instruction produced exactly one LATE
with first-bad `$42`; `TRB dp` made to jam produced exactly one NO-RETURN with first-bad `$14`,
the watchdog rescuing the battery. Picking that second injection is not free — `SED` and `CLC`
both hang the cart *before* `A6.15` runs, because the runtime and earlier Group A rows execute
them. `TRB` is executed nowhere else on the cart.

### Fixed

- **The `A6.15` watchdog read `RDNMI` through `DBR`, and it cost a false accusation of a reference.**
The NMI handler runs with whatever data bank the sandbox left — `$7E` — so `lda $4210` read a WRAM
byte and the NMI was never acknowledged. Three hosts happened never to land an NMI where it
showed; **ares did**, and the row reported `PLA` (`$68`) overshooting on ares alone, stably, with
every other opcode agreeing.

That reads exactly like a reference bug, and it was ours. The chain that settled it is worth
recording: a diagnostic that replaced the terminator with `INX` fill showed **both** ares and
RustySNES resuming at the correct offset, so `PLA`'s length was never in question; sweeping only
`$68` passed on ares, so the failure needed the full sweep's elapsed time; and disarming the
watchdog made all four hosts agree. Long addressing (`lda f:$004210`) is DBR-independent and fixes
it. The handler now also preserves `A` — `RTI` restores `P` and `PC` but not the accumulator, and
an interrupt that silently rewrites `A` is not transparent to what it interrupted.

With the fix the injections are exact where they had been approximate: the jam injection reported
two stuck opcodes and 63 clean ones before, and reports one and 240 now.

- **`E2.10` — the full 256-opcode SPC700 cycle sweep.**
The cart measures how long every opcode the SPC700 can execute in a straight line actually takes,
and compares it on-cart against the cycle count **fullsnes** documents for it. The host supplies
no expected values; it reads back three bytes — opcodes measured, opcodes disagreeing, and the
Expand Down Expand Up @@ -100,24 +147,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

It belongs with `C13.*` and `F1.22`: enumerated, uncoverable, scored as such rather than chased.

- **`inidisp_forgot_to_force_blank`: the model difference is now identified exactly, and a recorded
claim about it is corrected.** The last per-dot framebuffer gap was documented only as "an
`internal_cgram_address` draw-ordering detail". The concrete difference: MesenCE updates
`InternalCgramAddress` inside `GetRgbColor`, called **only when `color > 0`** and **per layer
during tilemap render** — so a transparent pixel leaves the previous opaque column's value standing,
and several layers may update it within one column. RustySNES assigns it **once per column,
unconditionally, from the composited pixel** at the draw cursor. Structurally different models, not
an off-by-one.

**Gate-on-opaque is not the fix**, measured rather than assumed: wrapping the assignment in
`if ap.opaque` moves the ROM's hash from `0xaeb678a4165b28c5` to `0xa55bd66a1e6dd125` — still not
the MesenCE-agreeing golden — because gating the *composite* is not gating each *layer fetch*.

**Correction:** the AccuracySNES row that breaks under gate-on-opaque is **`C3.12`** ("CGRAM taken
in render"), not `C3.04` ("H counter advances") as previously recorded — `C3.04` passes. `C3.12`
is the row that asserts the redirect target directly, so its failure means the cart row and
MesenCE's model disagree about a backdrop column, and that needs adjudicating before either
changes. The real fix tracks the target in the fetch stage, per layer, on non-zero colour indices.
- **`inidisp_forgot_to_force_blank` is NOT a RustySNES defect, and the previous entry's account of
MesenCE's model was incomplete.** It was recorded as "the last per-dot framebuffer gap", to be
closed by tracking the CGRAM redirect target "in the fetch stage, per layer, on non-zero colour
indices". Reading the third reference changes the conclusion.

**The incomplete claim.** MesenCE was described as updating `InternalCgramAddress` only inside
`GetRgbColor`, "so a transparent pixel leaves the previous opaque column's value standing". It does
not. `SnesPpu::RenderBgColor()` runs **after** every layer render in a span and sets
`InternalCgramAddress = 0` for every backdrop column it fills, ascending. So MesenCE zeroes on
backdrop too — and what survives a span is decided by **pass ordering across the whole span**, not
by the last column drawn. That is an artefact of a span-based renderer, not a per-dot process.

**ares settles it.** `PPU::DAC::paletteColor` sets `latch.cgramAddress = palette` unconditionally,
called during per-dot priority resolution in the DAC — including `paletteColor(0)` for the
transparent case (`dac.cpp:71`). That is RustySNES's model exactly: one assignment per dot, from
the composited pixel, backdrop giving zero.

So RustySNES and ares implement the same per-dot model and MesenCE differs for architectural
reasons. The `7fc6` in the golden encodes **MesenCE's renderer**, not hardware. The row is
reclassified from a per-dot gap to a reference disagreement, and the engine is not changed to match
a pass-ordering artefact.

Two corroborations: `C3.12` ("CGRAM taken in render"), the scored cart row that asserts the
redirect target directly, **passes on Mesen2 and on RustySNES** — the two agree on the assertion
and differ only on this homebrew framebuffer hash. And `C3.12`'s own provenance is a per-dot
statement — "a CGRAM access during active display uses the colour the PPU **is drawing**" — which
the per-dot model implements directly and a span-ordering model only approximates.

This is the shape the Mode-5 first-pixel claim had, and the lesson from that retraction applied:
the third reference was read **before** publishing, not after.

- **Interlace scenes: the recorded Mesen2 nondeterminism is GONE, but interlace is blocked for a
different reason.** Probed and withdrawn. Three consecutive Mesen2 runs and two snes9x runs of an
Expand Down
9 changes: 8 additions & 1 deletion crates/rustysnes-test-harness/tests/accuracysnes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ const A5_08_SLOTS: [(u8, &str); 7] = [
///
/// Version-agnostic on purpose: this list grows whenever a Group E row records a slot, and naming
/// one release in the doc made it wrong the first time it did.
const GROUP_E_BATCH_SLOTS: [(u16, &str); 25] = [
const GROUP_E_BATCH_SLOTS: [(u16, &str); 29] = [
(256, "E8.01 KON sweep key-on mask (bit 7 = voice 0)"),
(
257,
Expand Down Expand Up @@ -629,6 +629,13 @@ const GROUP_E_BATCH_SLOTS: [(u16, &str); 25] = [
"B2.07 APU ticks over 48 frames (~6398 NTSC, ~7689 PAL)",
),
(276, "B2.07 measured frame height (261 NTSC, 311 PAL)"),
(
283,
"A6.15 opcodes returning at their documented length (expect 241)",
),
(284, "A6.15 opcodes returning LATE (expect 0)"),
(285, "A6.15 opcodes that did not return (expect 0)"),
(286, "A6.15 first opcode that was not clean ($00 = none)"),
(280, "E2.10 opcodes disagreeing with fullsnes (expect 0)"),
(281, "E2.10 opcodes measured (expect 231)"),
(282, "E2.10 first disagreeing opcode ($FF = none)"),
Expand Down
44 changes: 33 additions & 11 deletions crates/rustysnes-test-harness/tests/undisbeliever_golden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,42 @@ use rustysnes_core::{System, cart::Cart};
/// Frames to run before hashing (enough for the ROMs to reach their stable rendered pattern).
const FRAMES: u32 = 60;

/// ROMs that render differently — and, so far, *less* correctly — under the per-dot compositor than
/// the batch model, so their golden keeps the batch (MesenCE-agreeing) hash and the per-dot mismatch
/// is accepted here as a documented, pinned gap. Each entry pins the exact per-dot hash so a *change*
/// in the wrong output still trips the gate. Currently one: `inidisp_forgot_to_force_blank` does a
/// PPU access during active display without force-blank; per-dot returns `7fff` where MesenCE returns
/// `7fc6` — a Phase 4d (PPU access-during-render) gap. When 4d lands, remove the entry and re-bless.
/// ROMs whose per-dot output differs from the MesenCE-agreeing golden, with the difference
/// **adjudicated** rather than assumed to be ours. Each entry pins the exact per-dot hash, so a
/// *change* in this output still trips the gate.
///
/// The pinned hash was refreshed for the DRAM-refresh stall (`docs/dram-refresh.md`): the 4d gap is
/// unchanged, but the per-line refresh shifts the whole frame's timing, so the wrong output lands at
/// a different instant and hashes differently. The golden TSV entry stays the pre-refresh batch value
/// (it only needs to differ from the pinned value to route through this known-gap branch).
/// Currently one: `inidisp_forgot_to_force_blank` does a PPU access during active display without
/// force-blank; per-dot returns `7fff` where MesenCE returns `7fc6`.
///
/// # This is a reference disagreement, not a RustySNES gap
///
/// It was recorded as "a Phase 4d (PPU access-during-render) gap — when 4d lands, remove the entry
/// and re-bless". Reading the third reference (2026-08-02) overturned that:
Comment on lines +32 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the obsolete “Phase 4d pending” classification.

The new text classifies inidisp_forgot_to_force_blank as a reference disagreement, but PERDOT_KNOWN_GAPS and the existing branch at Lines 147-159 still report known per-dot gap (Phase 4d pending) and per-dot gap hash changed. Rename the classification and diagnostics so CI output does not identify an intentional reference mismatch as a pending RustySNES defect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rustysnes-test-harness/tests/undisbeliever_golden.rs` around lines 32
- 35, Update the `PERDOT_KNOWN_GAPS` entry and the related branch diagnostics
around `inidisp_forgot_to_force_blank` to use the reference-disagreement
classification instead of “known per-dot gap (Phase 4d pending)” or “per-dot gap
hash changed.” Keep the intentional mismatch handling intact while removing all
obsolete Phase 4d/pending-gap wording from CI output.

///
/// - **ares** (`sfc/ppu/dac.cpp:158`) sets `latch.cgramAddress = palette` **unconditionally**, in
/// `paletteColor`, called during per-dot priority resolution — including `paletteColor(0)` for the
/// transparent case at `dac.cpp:71`. That is RustySNES's model exactly: one assignment per dot,
/// from the composited pixel, backdrop giving zero.
/// - **MesenCE** writes it per *layer fetch* and then runs `RenderBgColor()` — which zeroes backdrop
/// columns — **after** every layer render in a span. What survives a span is therefore decided by
/// pass ordering across the whole span, which is an artefact of a span-based renderer rather than
/// a per-dot physical process.
///
/// So the `7fc6` encodes MesenCE's renderer architecture. Two corroborations: `C3.12` — the scored
/// AccuracySNES row that asserts the redirect target directly — **passes on both Mesen2 and
/// RustySNES**, so they agree on the assertion and differ only on this homebrew hash; and `C3.12`'s
/// provenance is a per-dot statement ("uses the colour the PPU *is drawing*"), which the per-dot
/// model implements directly.
Comment on lines +41 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Resolve the conflicting PPU specification.

The changed documentation states that MesenCE zeroes backdrop columns through span pass ordering. docs/ppu.md still states that transparent pixels retain the previous opaque address. Keep the specification consistent before merge.

  • crates/rustysnes-test-harness/tests/undisbeliever_golden.rs#L41-L50: align the harness explanation with the final PPU model.
  • CHANGELOG.md#L162-L170: align the release-note explanation with the same PPU model.
📍 Affects 2 files
  • crates/rustysnes-test-harness/tests/undisbeliever_golden.rs#L41-L50 (this comment)
  • CHANGELOG.md#L162-L170
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rustysnes-test-harness/tests/undisbeliever_golden.rs` around lines 41
- 50, Align the PPU documentation with the final per-dot/span-renderer model
rather than stating that transparent pixels retain the previous opaque address.
Update the explanation in
crates/rustysnes-test-harness/tests/undisbeliever_golden.rs lines 41-50 and the
corresponding release-note explanation in CHANGELOG.md lines 162-170; both sites
must describe the same backdrop-column zeroing and pass-ordering behavior.

Source: Path instructions

///
/// **Do not "fix" this by matching MesenCE.** The golden TSV entry stays the batch value; it only
/// needs to differ from the pinned value to route through this branch.
///
/// The pinned hash was refreshed once for the DRAM-refresh stall (`docs/dram-refresh.md`): the
/// difference is unchanged, but the per-line refresh shifts the whole frame's timing, so the output
/// lands at a different instant and hashes differently.
///
/// Unconditional: the per-dot PPU is the only compositor (the batch path was removed), so this crate
/// always exercises it and the gap list is not gated on any feature.
/// always exercises it and the list is not gated on any feature.
const PERDOT_KNOWN_GAPS: &[(&str, u64)] =
&[("inidisp_forgot_to_force_blank", 0xaeb6_78a4_165b_28c5)];

Expand Down
4 changes: 2 additions & 2 deletions docs/accuracysnes-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Every sub-group of Part V is enumerated, so this is a **complete** statement of
| `A3` | 10 | 10 | 0 | 0 | — |
| `A4` | 10 | 10 | 0 | 0 | — |
| `A5` | 15 | 14 | 0 | 0 | A5.20 |
| `A6` | 15 | 13 | 0 | 0 | A6.13, A6.15 |
| `A6` | 15 | 14 | 0 | 0 | A6.13 |
| `A7` | 5 | 5 | 0 | 0 | — |
| `A8` | 6 | 6 | 0 | 0 | — |
| `A9` | 3 | 3 | 0 | 0 | — |
Expand Down Expand Up @@ -52,7 +52,7 @@ Every sub-group of Part V is enumerated, so this is a **complete** statement of
| `F1` | 22 | 13 | 0 | 0 | F1.13, F1.15, F1.16, F1.17, F1.18, F1.19, F1.20, F1.21, F1.22 |
| `G1` | 18 | 15 | 0 | 2 | G1.13 |

**303 of 443** enumerated assertion rows covered by an on-cart test, plus **55** covered only by a rendered scene (`docs/adr/0013`) and **2** covered only by a host-side test — **360 of 443** in total.
**304 of 443** enumerated assertion rows covered by an on-cart test, plus **55** covered only by a rendered scene (`docs/adr/0013`) and **2** covered only by a host-side test — **361 of 443** in total.

The three columns are kept apart on purpose, in descending order of what the evidence is worth. An on-cart result means the same thing on any emulator and on real hardware; a rendered scene needs a host holding the golden; a **host-side** cover is this project testing its own code, which is the one thing AccuracySNES exists to stop being the only evidence. The host tier is admitted only where the cart *physically cannot* observe the assertion — the stimulus comes from outside the cartridge, or the subject is the loader rather than the machine — and every entry names the test and the reason (`dossier.rs::HOST_COVERED`). Adding the columns into one figure would quietly change what the number claims.

Expand Down
2 changes: 1 addition & 1 deletion docs/accuracysnes-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ AccuracySNES closed ticket **T-04**. The follow-on tickets minted here are **T-0
| | |
|---|---|
| Tests | **343** (scoring + golden vectors + region SKIP per image) — *tests, not assertions; see the note below the table* |
| Assertion coverage | **360 of 443** dossier assertions — **303 on-cart** + **55 rendered scenes** + **2 host-side**, kept as separate columns in descending order of what the evidence is worth (`docs/accuracysnes-coverage.md`) |
| Assertion coverage | **361 of 443** dossier assertions — **304 on-cart** + **55 rendered scenes** + **2 host-side**, kept as separate columns in descending order of what the evidence is worth (`docs/accuracysnes-coverage.md`) |
| Rendered scenes | **55** declared, all blessed and matching on both scene hosts (`docs/adr/0013`); **55** dossier rows have a scene as their only cover. One uses the `hires-even` extraction (`C5.15`, Mode 5) |
| Pass rate | **100.00%** on-cart, floor enforced at 1.00 by `tests/accuracysnes.rs` |
| Cross-validated | **Three references** as of `v1.29.0`. Mesen2 agrees on every test but `F1.03`, which clocks both ports out of one latch and so needs the port-2 input its Lua runner cannot drive; snes9x has 14 recorded divergences and **ares** 3, each with a citation in `scripts/accuracysnes/crossval.sh`. A headless **MesenCE** is separately the per-dot compositor's blueprint + exact-frame oracle. All images. |
Expand Down
9 changes: 9 additions & 0 deletions tests/roms/AccuracySNES/ERROR_CODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,15 @@ Provenance: **Contested** (superfamicom.org describes the wrap as happening "the

No failure codes — this is a **golden vector**. It cannot fail: it records what it observed and is excluded from the pass rate. Where the observation fits in a byte it goes in the verdict as a variant code (`(variant << 1) | 1`); where it does not — a dot count, say — the verdict is a plain pass and the value goes to the measurement channel at `$7E:E200`, which the host harness reads and prints. See the test's entry in `SOURCE_CATALOG.tsv` for its provenance tier and the reason it records rather than asserts.

### A6.15 — all 256 opcodes defined

Provenance: **Documented** (WDC W65C816S datasheet, Table 5-4 opcode matrix). Kind: scored.

| Code | Byte | Meaning |
|---|---|---|
| 1 | `$02` | the sweep did not execute 241 opcodes cleanly — it stopped early, or it covered a different set than the 15 documented control-transfer exclusions. Slots 283-286 hold the three counts and the first opcode that was not clean |
| 2 | `$04` | at least one opcode either failed to return or advanced PC by a different number of bytes than Table 5-4 documents for it. Slot 284 counts the late ones, 285 the ones that never came back, and 286 names the first ($00 there means none) |

### A5.S01 — Sweep: CLC

Provenance: **Documented** (WDC/GTE/VLSI instruction-operation tables agree; docs/accuracysnes-timing-oracle.md). Kind: scored.
Expand Down
Loading
Loading