Skip to content

RustyNES v2.6.4 — "Rubric" (OAM DMA lands and 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 found to have covered 88 of 146 entries. The emulation core is unchanged)

Latest

Choose a tag to compare

@github-actions github-actions released this 26 Aug 11:54
2a64d09

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:

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.

RETRACTION, added 2026-08-26 after publication. The two paragraphs above
are kept as written, and the reset they name did not happen. A probe on the
DUT's vector fetches over 60M cycles counts one RESET — at cycle 6, the
power-on one — against 1,445 NMIs and 67 IRQ/BRKs. The console never restarts.

Both of that section's mechanism claims were inferred from a LOCATION rather
than observed: first a hang, from the catalog ordering; then a reset, from the
menu loop plus the collapsed result count. Being at the menu with only
power-on results is equally consistent with never having LEFT it, and the
distinction needed one command — watching $FFFC.

What v2.6.5 actually found, by diffing the oracle's own AccuracyCoin run
against the DUT's on pc/bus_addr/bus_access, is a 6502 defect at cycle
11,434,938: the six absolute-Y read-modify-write illegals spent their dummy
cycle reading their own instruction stream instead of the operand address,
because the address override tested AM_ABSX alone. The assembler could emit
none of the twelve indexed RMW illegals, so no ROM in the corpus contained one
and all 72 gates were green over it.

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.