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
104 changes: 104 additions & 0 deletions .github/release-notes/v2.6.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# RustyNES v2.6.4 "Rubric"

**OAM DMA lands, all nine AccuracyCoin disagreements close — every rule that closed the last three stated by the test ROM and by neither nesdev page — and then the gate that certified them is measured to cover 88 of 146 entries.**

The emulation core is unchanged: no file under `crates/rustynes-{cpu,ppu,apu,mappers,core}` is touched, so **AccuracyCoin 141/141 (100.00%, RAM decoder)** and **nestest 0-diff** hold by construction — and were run anyway.

A rubric is the authoritative statement of the rules, written in the margin by the person who set the test. That is literally where all three of this release's fixes came from.

## OAM DMA, and the SH group — the two thirds of this release that came first

`$4014` was a register the console decoded and then did nothing with: **the DUT had never spent the 513 cycles an OAM DMA costs.** It lands here as a real bus master — halt on a read cycle, an optional alignment cycle, then 256 read/write pairs — implemented from `nesdev_wiki/DMA.xhtml`, with the documented **DMC-get precedence over OAM-get** (a DMC fetch delays the OAM transfer, and costs it its alignment as well as its slot). A DMA write now drives the open-bus latch too, which until an OAM DMA existed there was no bus master here to do.

Its halt and alignment were **fitted to the oracle first and then corrected from the wiki** — the correction is in the ledger rather than quietly squashed, because "measured rather than assumed" was written about a fit.

The **`SH` group** closes in two steps, and the second was named by the residual of the first: the stored value's AND with the address high byte is **RDY-conditional**, and the dummy-read cycle is **addressing-mode dependent** (`SHA (d),Y` is six cycles, so its `tcyc==3` is a pointer-high fetch, not a dummy read). Four absolute forms closed on the first fix and `$93` alone did not, which is what pointed at the second.

Together those took the vector from **9 differing to 3**. The three below are the tail.

## The three entries, and where their rules actually live

v2.6.3 left nine entries differing. This release closes all nine; the three below are the last of them. **Every one is category 1 — the implementation followed documentation that is true and insufficient.**

- **`Open Bus`.** A read of `$4015` does not drive the data bus, and its D5 is open bus. Both rules are properties of the board, so both live in `cpu_bus.sv`. The nesdev `Open_bus_behavior` page documents the `$4016`/`$4017` case and says nothing about `$4015` being exempt; AccuracyCoin states the mechanism outright — "all the values read here are internal to the 2A03 chip, so the data bus isn't used" — and its stimulus is `LDX #16 / LDA 40FF,X`, the exact instruction the trace divergence had been localised to independently.
- **`Interrupt flag latency`.** Branches poll before cycles 2 and 4 and **never** before 3, so a taken branch that does not cross a page has no poll at its last cycle. Not in the wiki at all.
- **`NMI Overlap BRK`.** Two rules: an interrupt sequence does not poll (stated plainly in the wiki and simply missed here), and the BRK hijack window was one CPU cycle too narrow at its late edge.

## What the codes meant, and the regression that read as progress

AccuracyCoin's `TEST_Fail` reports `(ErrorCode << 2) | 2`, and the runner sets `ErrorCode` to **1** before every test routine — so `Fail(N)` names test **N** of that routine, one-based. Read as a zero-based index it is off by one, and that error made a change from test 7 to test 5 read as *partial progress* when it was a **regression** that also broke a standing gate. The description reached a code comment before the macro was read. v2.6.3's reading that six entries "sharing one failure code" implied one shared cause is retracted with it: the code is an index within one routine, so two entries sharing it share nothing.

## A fix that closes one gate and opens another is a scope measurement

The first poll fix moved the poll for **every** instruction from the last cycle to the second-to-last, on the strength of the same ROM's walk-through of `CLI`. It closed the failing entry and regressed `apupulse026` and `blargg08`. All three compare against the same oracle, so one model satisfies all three and a change that cannot is not that model. Narrowed to the branch exception alone, the entry still passes and nothing regresses.

Similarly, the hijack's comment argued at length for a delayed pending flag and named this very AccuracyCoin entry as the case that would catch the alternative. The entry was then run, and it says the opposite. The wrong comment is kept beside the fix.

## Per-entry stimulus: the battery was the wrong iteration loop

`scripts/accuracycoin-build/build_sub_test_rom.py` builds a ROM that boots straight into one catalog entry. Twenty-six were vendored in Session 23 and nothing had been built since; two more land here — `open-bus.nes` and `nmi-overlap-brk.nes` — and all three used by this release are **standing verdict gates**. They reach their verdict from a cold boot with no input in 0.9M to 4.5M cycles against the battery's 17.9M.

They are verdict gates rather than bus gates by measurement, not preference: their per-cycle surfaces are dominated by the PPU I/O-latch difference this project has left open, 2,331,867 of 4,467,082 cycles on one of them. A gate cannot distinguish an open ledger item from a defect; the ROM's own verdict byte can. `tb/subtest_verdict.py` refuses when the oracle side is not itself a pass.

Also measured: **`sub-tests/cpu-open-bus.nes` does not run `Open Bus`** — its verdict lands at `$0407`, which the catalog assigns to *Dummy write cycles*. Off by one table row, and nothing had caught it because a fixture's name is not evidence about its content.

## The gate met its own wording, and the wording was not enough

With the four closed, the status vector reported **identical entry for entry across all 146 entries** — and **58 of those entries were `NotRun` on both sides**. The acceptance was worded so a DUT could not pass a test by skipping it; it does not cover the case where *neither* side runs it, which is what a short window produces.

By suite, the 600-frame golden reaches the CPU catalog and stops partway through `CPU Interrupts`. Every APU, PPU, sprite-evaluation and PPU-misc entry was `NotRun` — the entries rungs 3 and 4 exist for. **Measured: 4500 frames executes all 146** (134,012,761 cycles), and that is the golden now.

`accuracycoin_status` enforces it rather than merely documenting it: it prints a **coverage** line on every comparison and **refuses** with a non-zero exit when any entry is `NotRun` on both sides. The predicate and the refusal are both extracted from `main` and tested directly — a check that exists only inside `main` is a check no test can reach, which is how this property came to be missing.

## Widening the window found a defect on its first run

Moving the golden to 4500 frames was coverage work. The first run of the wider window **aborted at cycle 20,636,325** — 2.8 million cycles past where every previous run in this programme had stopped:

```text
DMA data disagrees at cycle 20636325 addr=$FFC0: rtl=$04 harness=$00
```

A halted CPU mid-`LDA $2007` held `ppu_sel` high through a DMC steal, so on the DMC's get cycle — where the bus address is the sample address — the mux preferred the PPU and handed the sample fetch the read buffer instead of the cartridge byte. **The comment directly above the offending line stated the intent it violated**: the write half of the exception was there (an OAM DMA targets `$2004` and the PPU must see it), the read half was not. `ppu_sel` now follows whichever master owns the bus.

It was localised with `DMA_PROBE_CYC`, a new env-gated per-cycle dump of both bus masters — necessary because the harness's cross-checks abort the run and the abort path does not flush `--bus-out`, so a divergence they catch cannot be read back from the trace.

## Rung 5 does NOT close, and the reason is a DUT defect

The full-catalog comparison is unavailable, and the first explanation for that was wrong. Measured on **both sides over the same window**, counting entries that have written a result:

| run length | oracle | DUT |
|---|---|---|
| 17.9M | 88 | **88** |
| 20.8M | 95 | — |
| 41.7M | 117 | — |
| 60.0M | 120 | **5** |
| 100M | — | **5** |
| 134M | **146** | **5** |

The oracle climbs monotonically to the full catalog. The DUT falls to five and stays there for seventy-four million cycles. A battery that had merely completed a pass and restarted would show the count climbing again.

The five are the whole **Power On State** suite and nothing else — the group a pass writes early. So the DUT restarts somewhere after 17.9M, completes Power On State, and then **produces no further result for 74M cycles**.

**The obvious reading was a hang inside `PPU Behavior`** — it follows `Power On State` in catalog order and had never once executed. Probing the PC refutes it: the DUT sits in a three-cycle self-loop at `$80DF`, which the ROM spells `INC $EC` / `JMP $80DF` — AccuracyCoin's **menu idle loop**, where the menu spins while its NMI handler works. The DUT is back at the menu with only the results the power-on path writes, so the console **reset**; it then idles because START is pressed once, at frames 300-306.

A reset is a different defect to chase than a hang, and v2.6.5 chases the right one: bracket where the result count collapses between 17.9M and 60M.

Reported as unavailable rather than as a result. Taken at face value the comparison reads `141 of 146 entries differ`, which would be a badly wrong thing to publish — it is one defect, not 141. The comparator does classify them as disagreements rather than as missing coverage, which is correct: they are `NotRun` on one side only, the exact distinction the acceptance wording exists for.

---

## Verification

| gate | result |
|---|---|
| sibling `./tb/regress.sh` | **72 passed, 0 failed** (69 + three new rung-5 verdict gates) |
| RTL mutations (interrupt timing) | **7 of 8 CAUGHT**; the eighth classified INERT by byte-comparison |
| RTL mutations (SH group) | **6 of 6 CAUGHT** — value rule, page-cross mangle, register selection, all five opcodes |
| `accuracycoin_status` mutations | **3 of 3 CAUGHT** |
| AccuracyCoin (oracle, RAM decoder) | **141/141 (100.00%)** |
| nestest | **0-diff** |
| `cargo fmt` / `clippy -D warnings` / rustdoc / `no_std` | clean |

## What is carried to v2.6.5

Rung 5. The full-catalog comparison is blocked on a **DUT hang** in the suites that had never executed — measured on both sides over the same window, the oracle climbs 88 → 95 → 117 → 120 → 146 while the DUT goes 88 → 5 → 5 → 5. Catalog order puts `PPU Behavior` immediately after the last suite the DUT completes, which is the next measurement rather than a finding.
11 changes: 9 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Document Version:** 2.1.0
**Last Updated:** 2026-08-20
**Applies to:** RustyNES v2.6.3 (the scheduling model is v2.0.0 "Timebase" onward)
**Applies to:** RustyNES v2.6.4 (the scheduling model is v2.0.0 "Timebase" onward)

This document fixes the high-level architecture of RustyNES. The per-subsystem specs under `docs/` (`cpu-6502.md`, `ppu-2c02.md`, `apu-2a03.md`, `mappers.md`, `scheduler.md`) take these decisions as given and elaborate one chip each. After reading this you should know the workspace shape, the scheduling model, the public boundary, and the load-bearing invariants. The canonical, always-current architecture spec is [`docs/architecture.md`](docs/architecture.md); this file is the top-level companion.

Expand Down
Loading