RustyNES v2.6.9 — "Abeyance" (an exclusion hides improvement as well as regression)
RustyNES v2.6.9 "Abeyance"
The cartridge arrives, and an exclusion hides improvement as well as regression.
Abeyance is a state of temporary suspension in which a right still exists and nobody is exercising it. That is exactly what a deny-list entry is: the coverage is still there, still computable, and nothing looks at it. v2.6.8 established that a deny list is an assertion about the thing under test and re-measured six entries, retiring four. This release goes one level down, to the mechanism — and finds that a denied stream is denied entirely, so a golden whose divergence is one cycle out of 357,361 forfeits the other 357,360.
The emulation core is unchanged. No chip crate changes, so AccuracyCoin 141/141 (RAM decoder) and nestest 0-diff hold by construction — and were re-run anyway. The work is in the co-simulation harness.
Precisely: the oracle side changes one Rust file, crates/rustynes-test-harness/tests/mmc3.rs, and that change is 33 added lines of //! doc comment with zero deletions and no executable line touched. It records a defect this release found in this emulator — see "the DUT is now ahead of its oracle" below — beside the existing note rather than replacing it.
The gate
| # | criterion | result |
|---|---|---|
| 1 | the new instrument works | obs_diff9.py localised both open divergences to a cycle and a field in one command each, and refuses a non-overlapping stream pair rather than reporting agreement |
| 2 | the allowance fails on a NEW divergence | CAUGHT — no allowance fails and names cycle 70,627 |
| 3 | the allowance fails on a STALE entry | CAUGHT — alone and alongside the real cycle. Plus: a cycle outside the window is refused, and a non-numeric argument is refused |
| 4 | coverage rises | apuconflict039 0 → all 357,361 cycles + 88 checkpoints; ppuoamcorrupt052 0 → 357,360 of 357,361 |
| 5 | no regression | 141 passed, 0 failed, 0 skipped — from v2.6.8's 128: 130 after the deny-list half, 135 with the cartridge gates, 141 with the blargg MMC3 battery |
| 6 | the oracle is untouched | AccuracyCoin 141/141, nestest 0-diff, both re-run |
| 7 | five cartridge boards match the oracle | UxROM, CNROM, AxROM, MMC1, MMC3 -- 0 divergences on every compared cycle (~178,676), 44 checkpoints each, 9 of 9 mutations CAUGHT |
The cartridge -- five boards, and the first consumer of A12
rtl/cart/cart.sv adds UxROM (2), CNROM (3), AxROM (7), MMC1 (1) and MMC3 (4) beside the existing NROM. Each has a generated stimulus ROM, an oracle golden, and both comparisons -- the per-cycle bus and the nine-field checkpoint stream this release built:
| board | mapper | bus | checkpoints |
|---|---|---|---|
| UxROM | 2 | 178,677 cycles, 0 divergences | 44 identical |
| CNROM | 3 | 178,676, 0 | 44 identical |
| AxROM | 7 | 178,676, 0 | 44 identical |
| MMC1 | 1 | 178,677, 0 | 44 identical |
| MMC3 | 4 | 178,677, 0 | 44 identical |
A bank switch is only observable if the banks differ, so every PRG bank is filled with its own bank number and the program -- running from the window the board keeps fixed -- switches, reads the switchable window, and stores what it read. And every bank-select write targets an address holding $FF: UxROM, CNROM and AxROM all have bus conflicts, cart.sv does not model them, and emulators differ on them. value & $FF == value, so the stimulus gives the same answer either way and these ROMs are about banking and nothing else. If bus conflicts are ever modelled, these ROMs keep passing and a separate one should test them.
The MMC3 counter found two PPU defects on its first day
Nothing in this core had ever looked at PPU A12 before. The gate opened at 10,821 of 178,676 cycles diverging, and five narrowing steps located it: the IRQ period was 568 CPU cycles against the oracle's 909; there were 1,445 filtered A12 clocks where about 960 were expected, which indicts the filter rather than the counter; a low-gap histogram put 480 extras at gaps of 5-6 M2 ticks; a per-dot histogram put all 480 at dot 338, on even scanlines only; and the address there was $1002, a pattern address where a nametable address belongs.
dummy_fetch named the RECORD dots, not the fetch. It covered dots 337 and 339. A fetch is two dots -- 337-338 and 339-340 -- so on the even dots the address fell through every window in chr_addr_raw onto the v_addr fallback and the PPU presented v itself. v bit 12 is fine_y[0], which alternates every scanline; that is the even/odd pattern, exactly. The fix had to split the signal, because the same name gated the fetch record too and widening it broke that.
Then the extras moved rather than vanished -- still 1,445, now 965 + 320 + 160 -- and a per-dot histogram put the remainder at dot 0, the idle dot: the only other dot of a rendering line that drives no fetch and therefore reaches the same fallback. The rung-3 record had already named that root cause for the octal latch, and fixing it there did not fix it here, because the two consumers are different expressions of it.
| before | after | |
|---|---|---|
| filtered A12 clocks | 1,445 | 965, every one at dot 261 |
| against the wiki's "241 per frame" | -- | 965 = 241 x 4 + 1 |
| IRQ assertions | 180 vs oracle 120 | 120 and 120 |
| interrupts taken | 218 vs 158 | 158 and 158 |
| diverging cycles | 10,821 | 950 |
3-A12_clocking passing is independent confirmation: it exercises the filter directly, and it passes only because the PPU stopped putting v on the CHR bus at dots 338 and 0.
The MMC3 residual closes — one cycle, not one scanline — and the DUT passes its oracle
/IRQ is now a registered output. The pending flag is a flip-flop and the CPU samples the pin, so the assertion the CPU sees trails the counter reaching zero by one CPU cycle.
| before | after | |
|---|---|---|
4-scanline_timing |
$02 — fails its first assertion |
$0C — sub-tests 2–11 pass |
1-clocking, 2-details, 3-A12_clocking, 5-MMC3 |
pass | pass |
6-MMC3_alt |
fail (by design — NEC rev B) | fail (by design) |
mapper4mmc3irq065 |
950 diverging cycles | 570 of 178,676 |
| suite | 135 gates | 141 gates, 0 failed, 0 skipped |
The value came from blargg's arithmetic, not from tuning. Its cli for sub-test 2 is fetched at cycle 1,250,755, so end_ runs cli(755–756) nop(757–758) nop(759–760) inc irq_flag(761–765), and the filtered A12 rise is at 1,250,759. Asserting combinationally raises /IRQ inside the second nop, so the handler beats the inc; one cycle later it lands after it. A 0–8 cycle sweep picked 1 uniquely — 0 fails at sub-test 2, 2–5 overshoot to sub-test 3.
And it models a measured effect. lidnariq's oscilloscope trace of an MMC3B gives "approximately 69ns from the first time PPU A12 rises to 2.4V until /IRQ falls to 1.0V" — about a third of a pixel — and that thread names this exact consequence, that the delay can push the IRQ into the next instruction for some alignments. A sub-cycle analog delay becomes a whole-cycle shift at the CPU's sampling instant.
Two of this release's own diagnoses were retracted to get here, each refuted by a ROM written for the rule: a flag-vs-zero reload distinction (killed by 2-details sub-test 7) and suppressing the pre-render A12 clock (killed by sub-test 8, the 241-clock test). The "one scanline" reading came from diffing the DUT against the oracle rather than against the ROM's own boundary — which is the finding below.
The DUT is now more accurate than its oracle, and that is a first
ADR 0037 accepted in writing that "the oracle can be wrong". This is the first time the ladder has demonstrated it.
RustyNES fails mmc3_test_2/4-scanline_timing at sub-test 3 — its own test suite asserts that, escape-hatched as "CLOSED by-design-permanent (ADR 0002 F5.0)". The DUT now fails at sub-test 12.
The oracle's own PPU state trace says why: on scanline 261 with rendering on, sprites at $1000 and spr_count = 0, it produces no counter clock, while scanline 0 with identical sprite state clocks normally. Its /IRQ is therefore a scanline late — 1,250,873 against 1,250,760 on identical CPU streams — and its recorded failure message, "Scanline 0 IRQ should occur sooner", is exactly that lateness. The NESdev MMC3 page requires the clock: filtered A12 "oscillates exactly one time per scanline and 241 times per frame", sprite patterns fetched "even if no sprites are visible".
This is why mapper4mmc3irq065 cannot reach zero from the DUT side. Its 570 diverging cycles are entirely MMC3 IRQ timing, and closing them would mean reproducing the oracle's defect and giving back ten blargg sub-tests. It stays deliberately unregistered, numbers re-measured at the shipping commit. The route to zero runs through the oracle and is written up as T-ORACLE-001 in to-dos/ROADMAP.md, with reproduction commands, the acceptance bar (AccuracyCoin re-measured at 141/141, never asserted — it is a core change), the two instrument traps that waste time, and the controls that would refute it.
blargg's MMC3 battery becomes a standing gate
It had none, so the fix above had nothing protecting it. Six ROMs, each compared against its own verdict byte — deliberately not against the oracle, for the reason above. tb/blargg_verdict.py refuses rather than passes when there is no status line, no $DE $B0 $61 signature, or the ROM was still running at the budget: an absent verdict must never read as a pass. Every expectation is asserted exactly, including the two failures, so an improvement fails the gate and gets looked at rather than drifting quietly.
The one still-open residual has a documented mechanism waiting for it: sub-test 12 is the inverted $2000=$10 configuration, where the wiki says "the missing dot on the 2C02 causes the very first scanline to sometimes count twice" — the odd-frame dot skip, not another timing constant.
blargg scores the DUT level with the oracle, and one residual is attributed rather than fitted
On blargg's mmc3_test_2 the DUT and the oracle both pass 4 of 6, failing the same two ROMs — and one of those failures is correct: 6-MMC3_alt is the NEC rev-B behaviour, and this core deliberately models the other revision, which is what the oracle targets too.
The real residual is 4-scanline_timing #2, and it is measured rather than described. Both consoles write the arming $E001 on the same cycle, so their CPU halves are in lockstep; the whole difference is which A12 rise clocks the counter, and it is exactly one scanline — the DUT's IRQ comes from a clock on the pre-render line, the oracle's from the next line.
A fix was written for it and refuted, which is the useful part. $C001 clears the counter and sets the reload flag, so the next clock reloads to zero and fires; suppressing the IRQ on a flag-driven reload (as opposed to a zero-driven one) is worth exactly one clock. Plausible, documented-adjacent, and wrong: 2-details — previously green — dropped to Fail($07), and sub-test 7 is written for that rule and states the opposite outright, "IRQ should be set when non-zero and reloading to 0 after clear". Reverted from a pre-edit snapshot.
The same ROM eliminates the other candidate. Sub-test 8 is "Counter should be clocked 241 times in PPU frame", in the same PPUCTRL = $08 configuration, and the DUT passes it — 241 being 240 visible lines plus the pre-render line. So the pre-render clock is required, not extra.
Both rules the DUT implements are therefore confirmed by ROMs written to probe them, which puts the residual upstream of the cartridge entirely. Settling it needs the oracle's own filtered-A12 stream, an instrument neither repository has today. It is recorded with its cycle, dot and scanline, and 4-scanline_timing stays red on both consoles — attributed, not tuned until a gate goes green.
The larger denied stream was never the console
apuconflict039's bus surface had been excluded since v2.6.2 under a note saying it "carries nine divergences by design and is gated on channel levels only."
Two things were wrong with it, and the first is the release's thesis in miniature: six of the nine had already closed, and nobody could see it. A denied stream is denied in both directions, so an entry that silently improves is exactly as invisible as one that regresses.
The three that remained were not by design either. On a cycle where the CPU is held, tb/cpu_main.cpp built the Observable's bus_data from a stale local (last_bus_data) rather than from the RTL's own latch. The two differ on exactly one rule, and it is a rule the RTL already implements correctly: a read of the APU status register is internal to the 2A03 and does not drive the external data lines, so open_bus_q holds across it while last_bus_data — which tracks what the CPU received — takes the register's value. A DMC steal immediately after that read then logged the internal value on three held cycles where the bus was still carrying the previous operand fetch.
The harness was duplicating, badly, a model the DUT already had. Reading the latch instead of re-deriving it is both shorter and correct:
| before | after | |
|---|---|---|
apuconflict039 bus surface |
excluded — "nine divergences by design"; 3 still differing when measured | identical on all 357,361 overlapping cycles |
| its checkpoint stream | excluded | 88 of 88 identical |
The phrase is the mechanism. "By design" reads as a settled property of the thing under test; here it was a property of the instrument reading it, and that is what stopped anyone from re-checking for seven releases. The plan for this release predicted the remaining divergences would be a DUT defect, reasoning from the same rule about the status register — sound reasoning, right rule, and the defect was one layer further out. A measurement disagreeing with a correct rule can indict the measurement.
The planned mechanism was refuted by its own mutation pass
The deliverable was specified as ckpt_diff.py --expect-diff: a list of checkpoint indices known to differ, failing on any unlisted divergence and on any listed checkpoint that now matches. It was implemented and run before it was trusted, which is the only reason it did not ship.
It cannot work. checkpoint.h chains a rolling FNV-1a, so one divergent cycle poisons every checkpoint after it. Allowing ppuoamcorrupt052's first differing window moved the failure straight to the next one; allowing the rest is the all-or-nothing deny it was meant to replace. "Recovering 87 of 88 checkpoints" was never available by that route.
A per-cycle comparison has no such coupling, so the allowance moved there. An attributed difference now costs one cycle of coverage instead of seventy-one checkpoints.
ppuoamcorrupt052, allowed and named
It differs on exactly one cycle: 70,627, bus_data $80 on the oracle against $00 on the DUT. That is ledger 3.19's documented OAM-corruption asymmetry — the DUT implements all three seed windows from AccuracyCoin's own source, the oracle implements one, the DUT implements more of the documented rule than the oracle does, and no gate available to either repository can adjudicate it. It is allowed and attributed, not resolved.
Both failure clauses survive the move intact, and the second is the point: an allowed cycle that stops differing is a FAILURE. A DUT that improves cannot leave a stale allowance quietly hiding coverage — which is precisely the defect this release opened by finding.
What did NOT close
Rung 6. No DE10-Nano and no SuperStation One are attached to this machine — confirmed by checking the USB bus, serial devices, removable block devices and mounts, not assumed. The palette, the video timing constants, the audio's absolute level and its band-limiting remain unverified by construction, because every gate in the ladder compares something upstream of all four.
C1, the $2006 v-copy delay depth, is still under-determined at 1–4 dots.
Links
- Plan:
to-dos/plans/v2.6.9-abeyance-plan.md - Rung 6 record, including the retracted "by design" paragraph:
RustyNES_MiSTer/docs/rung6-integration.md - The instrument:
RustyNES_MiSTer/tb/obs_diff9.py
No bitstream accompanies v2.6.9, and here is why
Every release since v2.6.7 has shipped a .rbf. This one does not, and the absence is a disclosure rather than an oversight.
v2.6.9 changed the RTL (cart.sv's registered /IRQ, ppu2c02.sv's dummy_fetch split), so the committed RustyNES_MiSTer-v2.6.8.rbf no longer represents this core — do not treat it as v2.6.9's. Building a new one failed at Analysis & Synthesis:
Error (276003): Cannot convert all sets of registers into RAM megafunctions
The rung-7 cartridge had never been through Quartus. chr is written from two separate always_ff blocks — the ROM load port and the CHR-RAM write path — and an array with two writers in two different always blocks cannot infer as a single M10K. Quartus therefore kept 128 KB of CHR in flip-flops: 1,048,576 registers against roughly 166,000 on a 5CSEBA6U23I7.
scripts/release-rbf.sh refused to publish, which is the behaviour it was built for: it checks errors and timing closure against the Quartus reports, not the exit code.
The fix is understood and verified at the synthesis level — folding both writers into one port makes all three cartridge memories infer (prg_rtl_0, chr_rtl_0, prg_ram_rtl_0, 666,054 block-memory bits, 0 errors) — but it is not verified against the 141-gate co-simulation suite, and a published version is immutable here. So it lands in the next release, which is also the one that can carry a bitstream. Branch: fix/cart-m10k-inference.
This is the same class of finding as v2.6.6's, one layer out: nothing had asked Quartus about the cartridge, and simulation cannot ask that question.