MiSTer FPGA and SuperStation One - the co-simulation boundary¶
Spec, not history. Update this file in the same change as any behaviour
change to crates/rustynes-cosim or the golden formats it emits.
Decision records: ADR 0037
(the programme and the HDL firewall) ·
ADR 0038 (the interrupt-injection API).
Execution plan: the line to v3.0.0, which shipped on 2026-10-06 (ADR 0041, as amended by ADR 0043):
v2.7.x (the core and frontend audits; oracle-only, so the bitstream does not change),
v2.8.x (the RTL audit and the off-die build),
v2.9.x (the board) and
v3.0.0, which ADR 0043 made the API major with a
release-candidate core; the hardware-verified core is the later
v3.x release. Before that,
to-dos/plans/v2.7.0-mister-core-plan.md (superseded)
-- which superseded v2.5.0-fabric-plan.md,
which is delivered.
Execution tracking: to-dos/mister/.
Research archive: to-dos/plans/research/v2.5.0-research-mister-fpga.md,
plus four dated files in ref-docs/ (contribution requirements, the MiSTer
framework, the hardware source map, and alternative FPGA targets).
Device-under-test: https://github.com/doublegate/RustyNES_MiSTer (private).
The ladder becomes something a reviewer can run (v2.6.15)¶
Rung 6 is blocked on hardware and v2.7.0 now waits for it, so this release is about the other half of a submission: not what the core does, but what a reviewer can check about it.
The contributing page states the bar for AI-assisted code in one sentence --
"Fully AI generated code should meet a minimum reasonable bar for readability
and include some evidence of quality and accuracy testing." This programme's
evidence is 148 co-simulation gates -- 142 at the start of v2.6.15, plus the five
it added and the one v2.6.16 added. Every gate but five carries a mutation
record; the exceptions are the five blargg cpu_interrupts_v2 verdict gates
v2.6.15 added, which are independent-oracle verdicts with no mutation recorded
against them (no rung document and neither tb/mutate.sh nor
tb/mutate_apu.sh names them; noted at v3.0.1). 147 of them run on the die; the 148th measures the off-die memory system and reports N/A
there, because a gate on the SDRAM path cannot exist in a build with no SDRAM. And
tb/regress.sh says in its own header that it "is NOT a CI gate and cannot
be", because it needs the oracle's goldens and a cargo build of a crate in
another repository. So the strongest thing here was a set of documents
describing checks a reader cannot run.
docs/golden-fetching.md in the sibling specified the fix and carried
Status: NOT BUILT. The smallest end-to-end slice is now built: the nine
opcode-group ROMs export from a pinned oracle commit (tb/ORACLE_COMMIT)
and compare in CI. It is a subset -- no PPU, no APU, no AccuracyCoin, no nestest
-- and the job's name says so, because a job that looks like a gate and is not
is the defect this programme keeps finding in itself.
Pinning is the point rather than a caveat. The determinism contract covers the framebuffer and the audio and says nothing about trace-format stability, so an unpinned oracle could turn the sibling red for a reason unrelated to its RTL. Green there means green against one recorded commit, and moving the pin is a deliberate edit.
The first independent interrupt oracle¶
docs/rung5-accuracycoin.md carries the sentence this closes: "Rung 4 had
blargg as an independent check and it found six defects no self-written gate
could see; rung 5 has no equivalent, and that is the single most important
sentence in this document." Interrupts had the same hole -- every interrupt
gate compares the DUT against RustyNES, so a shared error between them is
invisible by construction.
cpu_interrupts_v2's five single-purpose ROMs are now verdict gates, on
mapper 0 (the combined ROM is mapper 1; the singles are not, which removes
MMC1 as a variable). 5-branch_delays_irq is the sharpest of them: "A taken
non-page-crossing branch ignores IRQ during its last clock, so that next
instruction executes before the IRQ." That is the exact behaviour v2.6.7
changed in the oracle -- caveat C6, skip_irq_sample_q -- from documentation
reasoning alone, with no ROM adjudicating it. This one adjudicates it, on both
consoles.
Alignment is recorded rather than assumed: blargg's readme says
2-nmi_and_brk "Occasionally fails on NES due to PPU-CPU synchronization", so
its verdict is alignment-sensitive by design and a pass is a pass at the shipped
alignment, not an absolute one.
T-ORACLE-001's opening claim is retracted¶
The ticket says RustyNES never clocks the MMC3 counter on the pre-render line.
It does. mmc3_test_2/2-details sub-test 8 is, verbatim, "Counter should be
clocked 241 times in PPU frame" -- 240 visible plus the pre-render line -- and
RustyNES passes it, as it has every release.
The claim came from --ppu-state-trace, and the ticket's own instrument traps
section says why that instrument cannot answer the question: it carries no CHR
address column, so it reports what the sprite state was and never what address
was driven. A trace that could not see the event was read as evidence the
event did not happen. This is the second time an instrument has been mistaken
for its subject here -- v2.6.9's apuconflict039 was the first, where nine
divergences "by design" were a defect in the testbench.
The verdicts are unchanged: the oracle still fails 4-scanline_timing at
sub-test 3 and the DUT at sub-test 12, so on that ROM the DUT is still the more
accurate of the two and mapper4mmc3irq065 stays unregistered. Only the
explanation was wrong.
Three claims that became checks¶
sys/ verbatim rested on one measurement taken at v2.6.6 plus a manual
procedure nothing ran -- eight releases, several of them touching the build. It
now pins all 57 files and catches a changed, a missing and a stray file; the
third mode is why the directory is enumerated rather than the manifest merely
walked, and it is not hypothetical, since sys/README.md and sys/.gitkeep
both lived there through v2.6.5. Re-verified against upstream in the same pass:
Template_MiSTer's HEAD is the pinned commit and a clone reports 0
differences -- two claims, measured separately, because a vendored tree can be
faithful to a commit upstream has moved past.
The .qsf published two seed sweeps disagreeing about the pinned seed's
margin by 0.155 ns, one of them a number the current RTL cannot reproduce. And
the bitstream name turned out not to be a style question at all: the
distribution builder skips any file whose stem does not end in an underscore
plus eight digits, so a version-named core would have appeared in the Cores
table and shipped nothing.
Rung 4 OPENS: the pulse channels and the frame counter (v2.5.9)¶
Both pulses -- timer, 8-step duty sequencer, length counter, envelope, the
sweep MUTE -- plus the frame counter in both modes with its IRQ and the
$4015/$4017 register file. Triangle, noise and DMC are v2.6.0/v2.6.1.
The partition was fixed before the rung (the sibling's docs/rung4-apu.md),
because the APU is the hardest chip here to gate honestly: what it produces is
an analog level and what an emulator computes is a number. Gates are the
$4015 read value, the /IRQ pin and each channel's integer DAC input;
MixRecord's f32 mix fields, the sequencer step index and apu_phase are
diagnostics. --apu-trace on nes_golden_export exports the integer levels
only.
The stimulus measurement found four ROM defects before any gate ran --
length index 3 is 2 and not 254, the 6502 boots with I set so CLI is
mandatory, two channels at one volume are indistinguishable, and power-on work
RAM is seeded rather than zeroed. Four findings in the DUT: the duty sequencer
counts UP, the 4-step constants must be consistently 0-based, $4017 bit 7
clocks a quarter and half frame IMMEDIATELY, and the $4017 reset delay depends
on bit 7 -- which the wiki's "3 or 4 CPU clock cycles" does not settle.
apulen027 is exact on both surfaces; apupulse026's bus surface is 3 and its
channel levels 1,000 -- 500 runs of exactly two cycles, one per edge, a
uniform one-tick $4003 write-parity sensitivity the first stimulus hid. Two
fixes tried, both rejected by measurement. v2.6.0's first item. Nine of ten
mutations CAUGHT, the two exceptions both indicting the stimulus.
Rung 7 opens: the cartridge, and two PPU defects A12 found (v2.6.9)¶
Five boards land -- UxROM (2), CNROM (3), AxROM (7), MMC1 (1) and MMC3 (4)
-- in one rtl/cart/cart.sv whose mapper number is a runtime INPUT, because on
MiSTer the header arrives with the game and a parameter would need a different
bitstream per cartridge. All five match the oracle on every cycle and every
checkpoint, and nine of nine mutations are CAUGHT.
"Five" here is rung 7's boards, not the console's. NROM landed at rung 5, so the core supports six: NROM, MMC1, UxROM, CNROM, MMC3 and AxROM. Both counts are correct and they are easy to reconcile wrongly -- a reviewer did, reading this line against the sibling's six commercial captures.
Half the earlier "rung 7 is blocked" claim was wrong, and this corrects it. Rung 7 has two parts and only one ever needed hardware: the SDRAM controller, whose acceptance is read/write timing against a real part. The mapper logic is pure logic, verifiable exactly as rungs 1-5 were. Treating them as one item deferred five boards behind a blocker that never applied to them.
The MMC3 scanline counter is the first thing in this core's history to consume PPU A12, and it found two PPU defects on its first day -- both invisible for four releases because nothing had looked:
dummy_fetchnamed the RECORD dots (337, 339), not the fetch. A fetch is two dots, so on the even dots the address fell through tov_addrand the PPU putvitself on the CHR bus;vbit 12 isfine_y[0], which alternates every scanline.- The idle dot. With the first fixed the extras moved rather than vanished, to dot 0 -- the only dot of a rendering line that drives no fetch, and so the only one still reaching the same fallback.
Filtered A12 clocks went 1,445 -> 965, every one at dot 261. The wiki
gives 241 clocks per frame, and 965 = 241 x 4 + 1 -- four frames plus one
clock, the run not ending on a frame boundary. Interrupts taken went 218-vs-158 to
158 and 158, with byte-identical work RAM, and the DUT/oracle divergence
from 10,821 cycles to 950.
The residual was two things wearing one appearance, and blargg separated
them. mmc3_test_2 scores 4 of 6 on the DUT and 4 of 6 on the oracle,
failing the same two ROMs -- 6-MMC3_alt correctly, since it is NEC rev B and
both target Sharp rev A. Within 4-scanline_timing the oracle fails sub-test #3
(its documented, permanently-deferred 1-cycle bracket, ADR 0002 F5.0) and the
DUT fails #2, "Scanline 0 IRQ should occur later when $2000=$08".
So part of the offset is the oracle's own residual, which must NOT be fitted to -- that is the failure the v2.6.0 audit named. The other part is a real DUT defect, shown pre-existing by control: with both PPU fixes reverted it still fails #2. It is left open with a 27-second reproducer and a validated pass/fail signal, rather than tuned away against a reference that is itself off on the same axis.
An exclusion hides improvement as well as regression (v2.6.9)¶
The co-simulation suite gates most goldens on a rolling per-cycle checkpoint hash, and carried a deny list of streams excluded from it. v2.6.8 established that such a list is an assertion about the thing under test — one that v2.6.7 had invalidated twice over by changing both the DUT and the harness — and re-measured six entries, retiring four. v2.6.9 goes one level down, to the mechanism, and finds two things.
First, "by design" was a claim about the instrument. apuconflict039's bus
surface had been excluded since v2.6.2 under a note saying it "carries nine
divergences by design". Six of the nine had already closed and nobody
could see it, because a denied stream is denied in both directions. The three
that remained were a defect in the harness: on a cycle
where the CPU is held, tb/cpu_main.cpp built the trace record's bus_data from
a stale local rather than from the RTL's own open-bus 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 lines, so the latch holds across it while a local tracking what the CPU
received does not. Reading the latch makes the stream identical on all
357,361 overlapping cycles and all 88 checkpoints.
The release plan predicted a DUT defect here and reasoned from the correct rule to get there. The reasoning was sound, the rule was right, and the defect was one layer further out — which is the standing lesson: a measurement disagreeing with a rule you are confident in can indict the measurement.
Second, a denied stream is denied ENTIRELY, so a golden whose divergence is one cycle out of 357,361 forfeits the other 357,360 — and an entry that silently improves is exactly as invisible as one that silently regresses.
The planned fix was an allowance by checkpoint index, and it was implemented,
run, and refuted: tb/checkpoint.h chains its FNV-1a, so one divergent cycle
poisons every checkpoint after it. Allowing the first differing window moved the
failure to the next one; allowing the rest is the all-or-nothing deny it was
meant to replace. The allowance moved to a new per-cycle nine-field
comparator, tb/obs_diff9.py --allow-cycle, where an attributed difference costs
one cycle instead of seventy-one checkpoints.
It fails both ways, which is what makes it adoptable: an allowed cycle that stops differing is a FAILURE, so an improving DUT cannot leave a stale allowance quietly hiding coverage. Six mutations confirm it, including a cycle named outside the compared window being refused rather than allowed to match nothing.
The one remaining entry, ppuoamcorrupt052, differs on exactly one cycle —
70,627 — which is the documented OAM-corruption asymmetry where the DUT
implements more of the documented rule than the oracle does and no available
gate can adjudicate. It is allowed and named, not resolved.
The bitstream is published from v2.6.7 (maintainer decision, 2026-08-30)¶
Every release from v2.6.7 ships a .rbf — committed to the sibling's
releases/ and attached as an asset to the GitHub release on both
repositories. This reverses v2.6.6, which produced a bitstream and deliberately
did not publish it.
Why the reversal is right. The MiSTer distribution mechanism reads the
.rbf out of the repository's releases/ — since v2.6.15 as
releases/RustyNES_YYYYMMDD.rbf, the only form both MiSTer parsers accept, with
the version-named RustyNES_MiSTer-vX.Y.Z.rbf attached to the GitHub releases
for people (the sibling's docs/bitstream-release.md) — so an empty
releases/ does not describe a cautious core — it describes an undistributable
one, withheld from exactly the people who own the boards this project does not.
And a claim nobody made is not the same as a claim marked unverified: only the
second is usable. So the caution moves from an absence into a disclosure.
What every release body must therefore state, because it is what the ladder does and does not reach:
- No hardware has run the bitstream (true until rung 6 closes). A booting core, a synced display, audible audio and a working controller are not claimed.
- The co-simulation ladder establishes per-cycle agreement with a 141/141 emulator on the declared compare surfaces, and AccuracyCoin agreement entry for entry across all 146 entries.
- Unverified by construction: the PPU gate compares the pre-palette index and the APU gate compares per-channel integer levels, so the palette, the video timing constants, the audio's absolute level and its band-limiting are downstream of every gate. That partition is deliberate — it is what stops a palette difference masquerading as a rendering one — and its price is that those four properties have no evidence behind them until a board runs the file.
- The Quartus version, device, error and warning counts, worst setup and hold slack, and the pinned fitter seed. The seed is load-bearing: two compiles of identical RTL have landed a framework HDMI path at +0.386 ns and −0.086 ns, so "timing closes" without a pinned seed is a statement about one placement.
Mechanism. scripts/release-rbf.sh <tag> in the sibling builds, verifies and
uploads. It checks errors and per-clock slack against the reports rather than
the exit code, because Quartus has been observed to abort after the resource
summary and still exit 0. Full procedure and rationale:
RustyNES_MiSTer/docs/bitstream-release.md.
One artifact, two boards. The SuperStation One is a Cyclone V console that
forks Distribution_MiSTer and consumes MiSTer cores directly, so the same
.rbf is what both take. Whether the identical file boots both is a hardware
claim and stays deferred.
Rung 3 CLOSES: VBlank, NMI, the $2002 race (v2.5.8)¶
The VBlank flag's full CPU-visible behaviour — the set, the clear, the
destructive read, suppress_vbl for the one-clock-before race, and the PPU's
/NMI wired to the CPU for the first time. Four ROMs carry it, every one with
its stimulus measured from the oracle's trace before any gate ran; a first
draft put a handler inside the power-on NOP slide and reset executed it, both
sides agreeing because both read the same wrong ROM.
Both structural fixes were deletions. The testbench's cycle split was
[2 pre-dots | access | 1 post] and the oracle's is [1 | access | 2]
(read_split(12) = (5,7)): a ~2-dot /NMI pulse from a read racing the VBL set
was invisible to the DUT's end-of-cycle sample, and PPU_LEAD=3 +
ACCESS_DOT=1 (same absolute access dot, moved boundary) fixed it — the
pulse-stretcher built first was measured dead and deleted. And
render_for_skip — v2.5.7's deferred skip-check delay — does not exist: the
oracle's two-PPU-clock rule plus the commit-edge sampling asymmetry lands
exactly on the rendering enable, so the extra tap was deleted and the pipe
shrank to one stage. ppuvbl024 had caught the DUT skipping ten pre-renders
the oracle never skipped, invisible to the bus gate for eight frames because
NMI delivery quantizes away single-dot drifts.
Twelve of twelve mutations CAUGHT — the last via a cadence-breaking frame, because its one firing landing (enable at pre-render dot 338, odd frame) is unreachable by any fixed-cadence ROM: the 3-dot CPU quantum and the skip's 1-dot drift lock odd-frame landings to one residue mod 3.
Reproducing it: none of these is a CI gate, so the exact invocations and
the non-zero count each prints are recorded in the sibling's
docs/rung3-ppu.md under Reproducing v2.5.8, exactly -- including that
--irq-trace is mandatory for every PPU golden export, without which no
obs.bin is written and the bus gate cannot run at all.
nestest 0-diff at 5,002,992 cycles — the 5M window closes, and with it
rung 3's acceptance criteria in full. The harness serves open-bus $40 for
$4016/$4017; the next divergence anywhere is an APU or controller surface,
which is rung 4. Full record: the sibling's docs/rung3-ppu.md.
Rung 3 continues: sprite rendering, and the phase (v2.5.7)¶
Sprite rendering, priority, the sprite-0 hit and its no-hit-at-x=255 quirk, the
left-8 masks, the garbage nametable fetches and the 337/339 dummies — and the
release's real finding: the CPU–PPU power-on phase was wrong by two dots, and
every OAM window was compensating. The boot traces agreed on scanline/dot
at every instruction boundary while the per-cycle mappings differed by exactly
two — a phase error hidden by an equal record-point offset, two errors
cancelling. PPU_LEAD=2 (the earlier sweep tried 0 and 3; the answer sat
between them) moves every window from documented-minus-three to
documented-minus-one, which is registered-assignment semantics and no residual
fudge — the code came to what the RTL's comments had claimed all along.
Every gate in the rung is exact for the first time: ppuspr019/020/021
119,115 cycles each, ppuscroll 49,998, ppusprender 119,114, ppusprite
59,993, ppuregs 12,841, fetch traces 7,058 / 88,685 (the v2.5.4 narrowing is
removed — sprite fetches and the dummies are compared now), all three index
framebuffers 61,440, nestest 59,554 — plus ppu-phase-gate, new: the
inverse of cpu-gate's skip list, scanline/dot only over twelve frames,
98,562 records, the only gate that can see the odd-frame skipped dot's
one-dot-per-odd-frame drift (implemented this release; five mutations CAUGHT).
All ten of the rung's mutation catalog are CAUGHT, re-run in full at the
corrected phase — the tenth (sprite-0 flag read from the register only) by
ppuspr020 at exactly one divergence, the read landing on the hit dot. Also
found by instrument rather than argument: PPU_SUBDOT (master-clock-resolution
clocking, built to test the half-dot between read_split and write_split —
unobservable, now measured) exposed a register file gated by nothing; fixed
with cpu_ce, a one-clock commit strobe, latent until v2.6.5 where a held
address would have latched twelve times per access. Deferred with owners named:
the VBlank-race stimulus and the skip-check delay (v2.5.8), chr_wr (v2.6.3).
Full record: the sibling's docs/rung3-ppu.md.
Rung 3 continues: sprite evaluation (v2.5.6)¶
The evaluation FSM, secondary OAM, the eight-sprite limit, the documented overflow-search bug and the wiki's step 4. All 59,993 overlapping cycles match, with seven of eight mutations CAUGHT and the baseline verified passing first. The programme plan named this the hardest single item in it.
The gate observed a model the diagnostic did not expose, and that is the
finding worth carrying out of this step. ppu-state-trace carries
sprite_eval_n, sprite_eval_m and sprite_eval_found, which belong to the
oracle's real evaluation FSM. What a CPU read of $2004 returns does not come
from that machine: it comes from tick_oam_bus, a second, side-effect-free model
kept alongside it. So two edits made faithful to the traced fields each moved the
DUT away from the observable — 41 → 112 and 39 → 68 — and were reverted as
regressions. Adding oam_bus_copybuffer to PpuStateRecord at schema 2 is what
made every later measurement valid; it immediately showed the FSM sitting frozen
at n = 34 while the bus kept walking for the rest of the line.
One of those two regressions was then right. The overflow halt had been measured while phase 4 was itself mis-implemented, so it moved the DUT into a broken destination. Re-measured against a correct phase 4 it is worth 28 of the 39. A change rejected against a broken baseline is not a rejected change.
And the fix that closed it is the opposite of the obvious one. The wiki says
phase 4 copies OAM[n][0], but pinning the byte index to 0 is right on scanline
55 and wrong on scanline 58: phase 4 advances only the high half of the address,
so the low half keeps whatever ended the walk. Three of the four paths that
finish evaluation clear it; the sprite-eval bug path does not.
Two mutants are INERT rather than uncaught, and a first pass got that wrong.
The eval_ovf_cnt reset was reported as fixing a latent defect the stimulus
could not reach; the stale count in fact cannot occur — a probe fires zero times
at 528 window ends while its inverted predicate fires 528, the trace is
byte-identical without the reset, and the bound is structural (88 decide steps to
the latest hit, consumed by 91, in a 96-step window). It is kept as defensive
code, not as a fix. The second inert mutant is out of scope rather than
unreachable: sprite_overflow reaches the CPU only via $2002, which this ROM
never reads. Both were classified by byte comparison, because NOT CAUGHT has
meant four different things here.
Detail: docs/rung3-ppu.md in the sibling repository.
Rung 3 continues: background rendering (v2.5.5)¶
The shift registers, the fine-X multiplexer, the palette lookup and a per-pixel index — the first full frame this core has drawn.
Gate: ppurender all 61,440 pixels match, fifteen mutations all
CAUGHT, baseline verified passing first. The surface is the index
framebuffer, pre-palette, so a palette-table difference cannot masquerade as a
rendering one — different bugs, different rungs.
The oracle needed no change at all. index_fb.bin has been exported since
v2.4.1: the third rung step in a row costing the oracle side nothing, which is
what choosing the compare surface before the rung buys.
The fault was one pixel, and the incomplete fix identified the mechanism. The
first run differed on 46,730 of 61,440 pixels, which reads as a broken renderer
and was not: 13 distinct values on both sides, near-identical histograms, and
act[x] == ref[x-1]. The shift registers and the dot counter advance on the same
edge, so the documented "shift on dots 2-257" applied each shift one dot after
the pixel that should show it. Moving only the shift window took the first wrong
pixel from x=9 to x=17 — one tile further in — which is what proved the reload
was out of phase too. The resolution removes a register: the reload is the
pattern-high fetch's own dot, so it takes chr_din directly.
Five NOT CAUGHT mutations indicted the stimulus, not the gate — and one of
them three times, for three different reasons: horizontal arrangement aliasing
the nametables, a zero coarse-X scroll whose only wrap copy_x undid before it
reached a pixel, and a fill whose 256-period ramp made both nametables
byte-identical. Each fix looked like it had closed the hole. Re-run mutations
after a stimulus change, not only after a code change.
fb_diff.py refuses a reference frame too uniform to test anything and is
demonstrated firing in all four paths; the window is read from the oracle's
manifest rather than transcribed. docs/golden-fetching.md in the sibling
repository now specifies the standing note that had sat across six releases —
including the requirement that golden fetching use a pinned oracle commit.
That pin is not a detail: the determinism contract covers the framebuffer and
audio and says nothing about trace-format stability, so an unpinned fetcher
would turn the DUT's CI red for a reason unrelated to its RTL.
Rung 3 continues: the background fetch pipeline (v2.5.4)¶
NT / AT / pattern-low / pattern-high fetches on the documented 8-dot cadence,
compared against the oracle as an address-bus trace -- real pins, so a
correct chip cannot differ, which is what makes it a gate rather than a
diagnostic under docs/rung3-ppu.md's partition.
Gate: ppufetch 6,247 background fetches / 0 divergences on scanline,
dot and address, across two rendering windows. Eight mutations, all CAUGHT,
baseline verified passing first.
The comparison was narrowed and said so on every run. At this release a
rendering scanline issued 154 fetches and the gate compared the 136 background
ones (dots 1-256 and 321-336), excluding sprite fetches and the two dummy
nametable reads; fetch_diff.py printed the excluded count for both sides
because a narrowing that is not announced reads as full coverage. The
narrowing is gone as of v2.5.7 -- COMPARED_DOT_SPANS is (1, 340), sprite
fetches and the dummies included.
The finding: the CPU access was presented two dots early, and no existing gate
could see it. The DUT issued one extra nametable fetch at the leading edge of
each rendering window and dropped one at the trailing edge, both by the same two
dots -- one quantity wrong by one constant, not two faults. The cause was in
tb/cpu_main.cpp, which presented the access on the second of the cycle's three
PPU dots; a 6502 commits a write and samples a read at phi2, the third.
Five gates stayed green across the move, in both directions: rung 1's registers on nine ROMs, rung 2's per-cycle bus, the interrupt sweep, and the v2.5.2 register and v2.5.3 scroll gates. That is not evidence the shift was harmless. Every one of them reads state once per CPU cycle, so a uniform two-dot shift in when a write lands inside a cycle moves nothing they compare. This is the rung's first gate keyed to the DOT counter, and the first that could see it.
nestest more than doubled. Rung 2's window was bounded at 27,396 cycles by a
missing peripheral -- nestest reads $2002 there and the testbench had no PPU to
answer. With the register file answering it now matches for 59,554 cycles,
2.18x the old extent, and the bound is an artifact budget rather than a wall.
Two mutations came back NOT CAUGHT against a correct gate, because the ROM
rendered from $2000 with PPUCTRL = 0 and so held v[11] and ctrl[4] at
zero throughout. The gate was not blind; the stimulus was. A second rendering
window at $2800 with background patterns at $1000 took the fetch count from
3,099 to 6,247 and both mutations to CAUGHT.
Also closed: four trace features had never been linted. No CI invocation
named cpu-boot-trace, irq-timing-trace, ppu-state-trace or the new
ppu-fetch-trace, and --workspace --all-targets reaches default features
only, so the rustynes-cosim clippy step compiled those modules as
dependencies, where warnings are not denied. One explicit step per feature
now; ppu-state-trace had six -D warnings errors waiting in it.
Rung 3 continues: the scroll address logic (v2.5.3)¶
inc_x, inc_y, copy_x and copy_y at their documented dots, the
$2007-during-rendering dual increment, and -- found by the rung rather than
planned into it -- a 3-dot delay on toggling rendering.
Gates: ppuscroll 19,813 records / 0 divergences; rung 2's bus
comparison on the same ROM 49,993 of 49,993 cycles matching on pc,
bus_addr, bus_data and bus_access; phase identical throughout.
"Toggling rendering takes effect approximately 3-4 dots after the write. This delay is required by Battletoads to avoid a crash." --
nesdev_wiki/PPU_registers.xhtml
This core applied a $2001 write immediately. The rendering window was a full
CPU cycle too wide at both ends, costing exactly one coarse-X increment and
invisible to everything except a read-back of v.
Both implementations were self-consistent; only the documentation could say which was wrong. The oracle can be wrong -- here it was not, and that was established rather than assumed, which is the rung-labelling rule from ADR 0037 doing its job.
Four instruments, each killing the previous hypothesis, and the order mattered more than any one of them:
tb/phase_delta.pyover every instruction boundary: the phase offset was constant, then zero once the testbench was fixed -- and the gate still failed. That proved the two faults independent and stopped an alignment change being credited with a fix it had not made.- Rung 2's bus gate on
ppuscroll: 3 of 49,993 cycles diverged, all read-back values, with the$2001writes byte-identical. Write-timing refuted. ppu-state-traceon the oracle -- the designated diagnostic -- showedvincrementing at 112, 120, 128, 136, 144 and 152. The sixth increment was at the END of the window, not the start.- The wiki adjudicated.
The diagnostic never became a gate, which is what docs/rung3-ppu.md
reserves it for.
Rung 3 has started (v2.5.2)¶
rtl/ppu2c02.sv implements the 2C02's CPU-visible register file: $2000-$2007
with $2008-$3FFF mirroring, the VRAM/palette bus and its read buffer, palette
and nametable mirroring, OAM, and the data-bus latch. 12,840 records, 0
divergences, 8 mutations all caught.
The compare surface is CPU-visible reads, carried by rung 2's existing bus
comparison -- a step that needs no new oracle format cannot be failed by one.
docs/rung3-ppu.md in the sibling was written before the rung and fixes
which fields may fail it (index_framebuffer, register reads, nmi_line, public
test ROMs) and which may only explain a failure (ppu-state-trace, v/t/x/w,
shift registers, anything RGBA).
The step also landed a behaviour the plan did not anticipate: writes to PPUCTRL, PPUMASK, PPUSCROLL and PPUADDR are ignored for ~29,658 CPU clocks after reset. Not modelling it made the DUT disagree on every VRAM and palette access while open bus and OAM stayed correct -- so the register file looked right.
And the ROM passed on its first run while testing nothing. Four defects, each
making both sides agree about a behaviour neither was being asked about; all four
found by mutation, none visible by reading. The lesson is recorded in the
sibling's docs/rung3-ppu.md: a passing gate is evidence only once something has
been shown to make it fail.
Rung 2 is closed (v2.5.1)¶
Its interrupt half was the last piece. tb/interrupt_sweep.py asserts /NMI,
/IRQ, or both together before instruction K and holds it, for every K across
a hazard program, driving identical stimulus into both sides: 60 injection
points, 0 divergences on all seven CPU fields.
It found two defects, and the second is the one worth remembering. A hardware
interrupt pushed a return address one byte too high, because AM_BRK fell
through a generic operand-fetch increment shared with every other addressing
mode. BRK and a hardware interrupt share that mode and disagree about it --
BRK advances over its second byte, an interrupt does not -- so for BRK two
writers assigned the same value and the fault was invisible. BRK passing
186/186 is what kept it hidden: the only opcode exercising the mode was the one
on which the bug did not show.
The oracle side is ADR 0038's
cosim-interrupt-inject feature. Its precondition -- that a default build emits
none of it -- is measured, with a live control: inject_ appears 0 times in
the expanded default core and 17 times with the feature on. The control is not
ceremony. The ADR's original command piped a missing cargo-expand through
grep -c, which reports the 0 it is looking for while measuring nothing.
Two v2.5.0 gates remain open and are reclassified, not carried. nestest 0-diff
and the 5 M-cycle window both stop at a $2002 read where both sides address it
and only the data differs -- the DUT has no PPU. They are rung-3 acceptance
criteria.
What this is, and what it is not¶
RustyNES is not being ported to FPGA. A MiSTer core is SystemVerilog compiled by Quartus 17.0.2 into a Cyclone V bitstream; Rust does not become a bitstream.
What the "Fabric" line builds is a new NES implementation in SystemVerilog,
written from public hardware documentation, in a sibling repository
(doublegate/RustyNES_MiSTer,
private), with RustyNES as its verification oracle. This document specifies
the boundary between the two - the one part that lives in this repository.
When this section was written (2026-08-20, first shipped in v2.4.2), the sibling repository held only the rung-0 harness and no RTL, which was the ladder's design rather than a gap: the testbench had to be shown able to recognise agreement before anything was compared. It has carried RTL since v2.4.4 and now holds a complete core (see the release sections above). Two of its harness files matter to readers of this document, because they are the other half of what is specified here.
tb/checkpoint.h reimplements this repository's checkpoint encoding in C++, and
tb/checkpoint_selftest.cpp asserts it against the same hardcoded vector
the_wire_encoding_is_pinned_to_a_fixed_vector pins on this side. That pairing
is the whole guard against the top-ranked risk at this rung: a packing
disagreement between the two halves produces a hash mismatch that is
indistinguishable from a wrong DUT, and would be debugged as one. Changing
Observable::encode here without changing checkpoint.h there breaks
co-simulation in the way that is hardest to diagnose - so the selftest is the
first thing to run after touching either.
Its licence audit also settled a question this side had left open. ADR 0037
recorded that a GPL-2.0-only file anywhere in the MiSTer framework's sys/
would force the RTL to GPL-2.0-or-later. All 57 files of an upstream clone were
read at v2.4.3: there is no such file, and four are GPL-3.0-or-later -
including hps_io.sv, which no core functions without. v2.6.6 re-verified this
against the tree actually vendored (Template_MiSTer@3ea1134c): 40 HDL files,
the same four GPL-3.0-or-later, nine GPL-2.0-or-later, zero GPL-2.0-only (the
sibling's docs/licence-audit-2026-08-20.md, "Re-verified at v2.6.6"). GPL-2.0-or-later combines upward and GPL-3.0-or-later does not
reduce, so the combined bitstream must be GPL-3.0-or-later, which is already
this project's licence. The hedge is inverted by the evidence rather than
confirmed by it.
The firewall applies to HDL¶
NES_MiSTer and fpganes rtl/ are strict black boxes: never opened, read,
quoted or transcribed. This is the same rule
docs/ai-emulator-provenance-guardrails.md states for emulator source, extended
to hardware description.
- Permitted: instantiating a third-party core as an opaque testbench module and comparing its outputs against ours.
- Not permitted: reading its source, constants, tables, identifier names or comments - not "for reference", not once.
- Anything unimplementable from documentation escalates to a new ADR before any source is opened.
Enforcement is mechanical rather than dispositional: the repositories stay
physically outside the workspace, and CI carries a path denylist plus an
identifier grep - the same shape as the /ref-proj/ guard.
crates/rustynes-cosim¶
A pure wrapper over rustynes-core. It adds no core API and changes no
core behaviour; it is additive, absent from the default build, and its presence
cannot move AccuracyCoin or nestest.
It is excluded from the workspace, deliberately¶
rustynes-cosim is in the root manifest's [workspace] exclude, not its
members. That is not tidiness — it is the only mechanism that makes the
isolation real.
The crate enables cpu-boot-trace and irq-timing-trace on rustynes-core, and
cargo unifies features across a workspace build. As a member, it made
cargo build --workspace compile the core once with the union, so every
workspace build linked the instrumented per-cycle PPU tick loop —
irq-timing-trace selects a different for sub_dot in 0..3 loop, not an inert
branch. CI's accuracy battery is cargo test --workspace --release --features
test-roms, so it was validating a scheduler no user runs.
The measured cost was +1.2% to +1.9% on full_frame, below this project's 3%
adoption bar. Stated precisely because it shows the fix was never about speed: a
gate pointed at the wrong code path is wrong at any percentage.
What exclusion costs, and how each cost is closed:
| Cost | Closed by |
|---|---|
Cannot use field.workspace = true; version/edition/license/lints duplicated |
cosim_manifest_audit.rs asserts every duplicated value equals the workspace's |
Someone re-adds it to members |
the same audit asserts it is still in exclude |
cargo fmt --all / clippy --workspace / test --workspace do not reach it |
explicit fmt, clippy and test steps in ci.yml |
Its dependencies leave the workspace cargo deny graph |
today only sha2, already in the graph; re-check if that changes |
The clippy step justified itself immediately, reporting a must_use_candidate
that cargo clippy --workspace had never surfaced.
Crate types¶
["rlib", "staticlib", "cdylib"] - rlib so the golden-export binary and the
crate's own tests use the safe Rust API directly, staticlib/cdylib so a
Verilator C++ testbench links the same code through the C ABI.
The trace features are mandatory¶
Cargo.toml enables cpu-boot-trace and irq-timing-trace unconditionally
rather than re-exposing them as this crate's own optional features.
A build without them would compile, link, run, and export empty goldens - an absence of signal that reads exactly like agreement. Making them mandatory turns that into a compile error.
A side effect worth recording: no CI invocation previously enabled either
feature for clippy, so cpu_boot_trace.rs and irq_trace.rs had never passed
the lint gate. Adding this crate surfaced six pre-existing findings in them.
The power-on frame latch¶
The PPU is constructed at dot 340 of the pre-render line, so the 7-cycle reset
sequence ticks past the frame wrap and leaves frame_complete latched. The
first run_frame() after construction therefore consumes that latch and returns
without stepping a single cycle - measured, not inferred: frame 0 advances the
cycle counter by 0, frames 1..3 by ~29,780 each.
Every other caller in the workspace runs thousands of frames, so one lost frame is
invisible to them. It is not invisible to a golden export: a bare
for _ in 0..n { run_frame() } would emit an (n-1)-frame golden under a manifest
claiming n.
Oracle::advance_frames(n) therefore gates on the frame counter, not the call
count, and bails out on a jammed CPU. The behaviour is pinned by
the_first_run_frame_after_power_on_advances_nothing, so a future core change
that removes the quirk fails a test that names it rather than silently altering
every golden's length.
Golden formats¶
nes_golden_export --rom <path> --out <dir> writes, under <dir>:
| File | Format | Consumed by |
|---|---|---|
<stem>.boot.bin |
CpuBootTrace binary - 12-byte magic RUSTYNES_CPU, schema version, packed records |
cpu_boot_trace_diff |
<stem>.irq.csv |
per-CPU-cycle IRQ/bus CSV, two samples per cycle | scripts/irq_trace_cross_diff.py |
<stem>.index_fb.bin |
256x240 little-endian u16, pre-palette |
the testbench's frame comparison |
<stem>.ram.bin |
2 KiB CPU work RAM | accuracy_coin_catalog::decode_results |
<stem>.ckpt.bin |
rolling per-cycle hash checkpoints, (u64 through_cycle, u64 hash) LE, headerless |
checkpoint_diff |
<stem>.obs.bin |
full-capture observable stream, repeated 16-byte records | the testbench's self-diff, and a located-window re-run |
<stem>.manifest.txt |
provenance | humans, and the drift guard below |
The framebuffer is exported pre-palette on purpose: a palette difference must not be able to masquerade as a rendering difference. That is the failure mode v2.3.8 "Parallax" was built to prevent.
The manifest is not decoration¶
The determinism contract covers the framebuffer and audio. It says nothing
about trace-format stability, and cpu_boot_trace is at schema version 1 with a
history of being reshaped. A routine RustyNES accuracy fix can therefore change a
golden and turn the FPGA repository's CI red for a reason unrelated to its RTL.
The manifest records the ROM SHA-256, the seed, the requested and actually
simulated frame counts, the run_frame() call count, the cumulative CPU cycle
count, and the emulator version - so a red diff is attributable to the right side
of the boundary in one look rather than by bisecting two repositories.
Requested and actual are recorded separately because they can legitimately differ: a ROM that jams stops advancing frames, and the export still succeeds. What must never happen is the manifest claiming a frame count that was not simulated.
The null-DUT gate¶
crates/rustynes-cosim/tests/null_dut_self_diff.rs is rung 0's half of the
ladder that lives here: feed RustyNES's own golden back in as if it were the DUT
and require zero divergences. It asserts three things, and the second and third
exist because the first alone proves less than it appears to.
- Two independent exports are byte-identical across the boot trace, the index framebuffer, work RAM, the cycle count and the call count. This is the determinism contract observed at this crate's boundary; if it fails, a pre-generated golden is not the trace a lockstep run would produce and replay-as-oracle is unsound.
- A one-bit corruption is caught. A comparator that always reports agreement passes a self-diff trivially.
- Five frames is five NTSC frames' worth of cycles, in six
run_frame()calls. Checked in integer half-cycles, without trusting the counter that produced the number.
Verified against the real cpu_boot_trace_diff CLI as well as in-process: it
reports All 5464 aligned records match and exits 0 on the self-diff, and on a
one-bit corruption reports the divergence at cycle 561, PC $C419, naming the
field and both values. The zero therefore comes from a tool that can tell the
difference, not from one that cannot.
The other half of rung 0 -- hash-checkpoint agreement with full capture over a 100k-cycle window, and the Verilator side of the writers -- is v2.4.2.
Replay, not lockstep¶
Build time: RustyNES emits goldens. Run time: Verilator runs the DUT, the C++ testbench writes the same byte formats, and the diff CLIs that already exist compare them.
The determinism contract makes this equivalent to lockstep - same seed + ROM + input yields a bit-identical framebuffer and audio, so a pre-recorded trace is exactly the trace a lockstep run would have produced. It is additionally better in two ways: goldens are re-diffable without re-simulating, and the two sides can run on different machines at different times.
scripts/mesen2_cpu_boot_trace.lua already writes cpu_boot_trace from a foreign
emulator, so the FPGA testbench is the format's third writer, not its first.
No DPI-C¶
DPI-C would push import "DPI-C" into RTL that must also pass Quartus, then
require `ifdef SIMULATION guards - the exact construct that lets a simulated
netlist drift from the synthesised one.
Instead, observation ports live in tb/nes_top_cosim.sv, never listed in
files.qip, plus Verilator --public-flat-rd hierarchical reads. Net synthesis
impact: zero.
Hash first, capture on divergence¶
A 4200-frame AccuracyCoin run is ~125 M CPU cycles, which as per-cycle CSV is ~7.5 GB per side. Both sides instead chain a 64-bit hash over the per-cycle tuple and compare checkpoints every 4096 cycles - ~480 KB for a full run. On the first mismatch, re-run only that window with full capture and waveforms.
Implemented in crates/rustynes-cosim/src/checkpoint.rs. Measured on a real
export rather than projected: 3 frames of AccuracyCoin is 89,343 CPU cycles,
which is 5,372,427 bytes of irq.csv against 352 bytes of ckpt.bin -
a factor of 15,263.
What is hashed, and what deliberately is not¶
CycleRecord carries 29 fields, and most of them are RustyNES's model, not
hardware. checkpoint::Observable is the subset an external device-under-test
can genuinely produce, and Observable::from_cycle_record is the single place
the partition is applied - so widening it has to pass
model_internal_state_cannot_cause_a_divergence, which perturbs every dropped
field at once and asserts the hash does not move.
| In | Why |
|---|---|
cpu_cycle |
the axis both sides count on |
bus_access, bus_addr, bus_data |
pin-visible |
put_cycle |
the R/W phase half of the M2 cycle - pin-visible |
nmi_line |
a pin |
irq_line_at_low, irq_line_at_high |
the /IRQ pin, sampled twice per cycle |
pc |
not pin-visible; see below |
Two caveats are stated rather than buried.
The IRQ line is one wire. CycleRecord splits its samples into
irq_pending_mapper_* and irq_pending_apu_*, which is RustyNES attributing
the assertion to a source. Hardware has a single wire-OR'd /IRQ input and cannot
make that distinction, so the pairs are OR'd before hashing. Hashing them apart
would fail a correct DUT for disagreeing about something it cannot observe.
pc is DUT-observable, not pin-observable. The 6502 does not expose its
program counter. It is in because the testbench wrapper can expose the internal
register and rung 1 compares it directly - but a pc-only mismatch means
something weaker than a bus mismatch.
ppu_scanline, ppu_dot, ppu_frame and a12_events are out. a12_events is
the sharpest case: A12 transitions genuinely are observable on the cartridge
connector, so it is excluded for scope, not observability, and becomes a gate
when the PPU rung opens.
The hash must be reimplementable in ten lines of C++¶
The top risk at rung 0 is a format-packing mismatch masquerading as an RTL bug.
So the hash is FNV-1a 64 - chosen for exactly one property, that a testbench
can reimplement it without a library - and Observable::encode defines a fixed
16-byte little-endian layout with an explicit zero pad byte, so the C++ side
cannot hash uninitialised struct padding. Both are pinned to a hardcoded vector
by the_wire_encoding_is_pinned_to_a_fixed_vector; a reordered field fails that
test rather than producing a phantom RTL defect on the next co-simulation run.
Three answers, and the third is the point¶
checkpoint_diff <reference.ckpt.bin> <candidate.ckpt.bin> exits 0 identical,
1 diverged (printing the window to re-run), 2 usage/IO, and 3
inconclusive. A truncated run, a DUT that stopped early, and two runs at
different intervals all produce "no divergence was found", and reporting that as
agreement is this project's recurring failure. A green job must mean the streams
were compared and matched, never that there was nothing to compare.
Cycle alignment is checked before the hash: two streams checkpointing at different cycles cover different spans, so a hash difference between them says nothing, and calling it a divergence would send a full-capture re-run at a window where nothing is wrong.
The gate: checkpoints agree with full capture¶
Checkpoints approximate "where do these two runs first differ", traded for four
orders of magnitude of disk, and the scheme is worthless if the approximation
can disagree with the answer. first_full_capture_difference computes the answer
directly; localisation_is_consistent states the contract, as a function rather
than as prose here.
| full capture says | the comparison must |
|---|---|
| identical | report Identical - anything else is a false positive, and a gate that cries wolf gets switched off |
first differs at k |
not report Identical - that false negative passes a wrong DUT |
first differs at k, and it reports Diverged |
name a window containing k |
first differs at k |
Inconclusive is an acceptable, honest refusal |
| identical | Inconclusive is not acceptable |
The third row is the one worth the effort. A divergence report naming the wrong window sends a full-capture re-run somewhere nothing is wrong, spends the debugging budget, and returns "no problem here" - which reads as evidence the DUT is fine.
A sweep drives 331 cases across every run length around the interval boundary,
corrupting a different observable field at a different position each time. It
found a real defect on its first run: a divergence at cycle zero was reported
in a window that did not contain it. after_cycle was a u64 in which 0
meant both "no prior checkpoint" and "cycle zero", so the first window read as
(0, 0] - empty. It is now Option<u64>, and Divergence::contains is offered
so call sites do not reimplement a boundary that is half-open at one end and
open-ended at the other.
The CSV cannot re-derive the checkpoints, so .obs.bin exists¶
Found by trying to build the rung-0 self-diff on the CSV. irq.csv carries 23
columns, and neither pc nor put_cycle_post is among them - two of the nine
observable fields are simply absent. So an external testbench reading the CSV
cannot reproduce the checkpoint hashes, and "feed RustyNES's golden back in as
if it were the DUT and get zero divergences" was not implementable as designed.
<stem>.obs.bin closes that: repeated 16-byte records in the same wire
encoding the hash folds, headerless. It is the only artifact the checkpoints
can be independently re-derived from, and it is also the input a re-run of a
located window consumes - so it would have been needed regardless.
Additive. The CSV is untouched, which matters because scripts/irq_trace_cross_diff.py
and the committed golden/irq_trace/*.csv both depend on its shape.
Observable::decode is the inverse, and it refuses what it does not
understand: a non-zero reserved pad byte, an undefined flag bit, an unknown
bus-access code, a short record, a stream length that is not a multiple of 16.
None of that is pedantry - reading a record from a newer producer as though
nothing had changed is how a format divergence gets reported as a DUT
divergence.
The stream is emitted even when the checkpoints are refused for overflow. A hash over a truncated trace claims a coverage it does not have; the records themselves are just records, and are worth keeping for a re-run.
The rung-0 self-diff, measured¶
The oracle's own output fed back in as though it were the DUT, across the repository boundary:
$ tb/selfdiff_check.sh
1/3 exporting goldens from the oracle
2/3 re-deriving checkpoints on this side
re-derived 22 checkpoints from 89335 records
3/3 comparing
checkpoints match: 22 compared, 0 divergences
negative control: one flipped bit must be located
DIVERGED at checkpoint 10
window to re-run with full capture: cycles (40967, 45063] (4096 cycles)
rung 0 self-diff: agreement recognised, and disagreement located
89,335 records of AccuracyCoin, re-derived in C++ from .obs.bin alone, hashing
to byte-identical checkpoints. The negative control runs in the same
invocation, because a positive control alone is satisfiable by a comparison
that always agrees.
It is not in CI: it needs both repositories and a test ROM. It exits 77 and says why when it cannot run - a skip that reports itself, never a silent pass.
A capacity-limited trace refuses rather than truncating¶
IrqTrace::push silently drops records once it reaches the capacity it was armed
with, advancing an overflow counter nobody has to read. A checkpoint stream
computed over a dropped-record trace hashes fewer cycles than it claims, and
the two sides then disagree for a reason that has nothing to do with the DUT -
which is worse than useless, because it looks like a legitimate divergence. So
Oracle::take_checkpoints returns CheckpointError::TraceOverflowed naming the
capacity to retry with, rn_write_checkpoints returns -5, and the exporter
aborts rather than writing a short stream.
One take, two artifacts¶
Bus::take_irq_trace moves the trace out, so asking for the CSV and then the
checkpoints yields None for whichever came second - and None is
indistinguishable from "never armed". Oracle::take_irq_artifacts derives both
from a single take; the hazard is pinned by
taking_the_csv_first_leaves_no_trace_for_checkpoints so it is a documented
behaviour rather than a surprise.
What is a gate and what is diagnostic¶
| Surface | Role | Why |
|---|---|---|
| nestest 7 CPU fields | gate | independent oracle - RustyNES is 0-diff against the Nintendulator log |
| per-cycle bus / IRQ samples | gate | where 6502 implementations actually die |
index_framebuffer |
gate | pre-palette, so palette cannot masquerade as rendering |
MixRecord integer channel levels |
gate | 0-15 per channel, 0-127 DMC |
| AccuracyCoin RAM status vector | gate | compared entry for entry, including Skipped / NotRun |
ppu-state-trace FSM fields |
diagnostic only | encodes RustyNES's modelling choices, not hardware facts |
mixed f32 audio output |
diagnostic only | the non-linear mixer and BLEP resampler are software artifacts |
The two "diagnostic only" rows are the load-bearing ones. Gating on
ppu-state-trace would force the HDL to transliterate a Rust data structure - bad
hardware, and an odd form of self-derivation given the black-box premise. Gating on
mixed f32 audio would either force the HDL to reproduce a software resampler or
produce permanent unresolvable false failures.
Partition every trace field into hardware-observable versus model-internal before the PPU rung starts.
The oracle can be wrong¶
141/141 on AccuracyCoin is not "matches silicon". Where RustyNES is wrong, co-simulation will drive the RTL confidently toward its bug.
Every rung is therefore labelled by whether it has an independent oracle: nestest (Nintendulator) and the blargg ROMs do; trace fields with no Mesen2 counterpart do not, and are advisory only.
It happened, at rung 5 (2026-08-25)¶
This stopped being a stated risk and became a measurement.
rtl/cpu_bus.sv was written from nesdev_wiki/CPU_memory_map.xhtml and
Open_bus_behavior.xhtml and run against this emulator. The two agreed on
$4016, $4017, $5000 and $5C34 — including the open-bus value, which the
DUT derives from a latch where the testbench had hardcoded $40 — and
disagreed only in $6000-$7FFF.
The DUT was right. An NROM board decodes nothing there, so the window reads
open bus. crates/rustynes-mappers/src/m000_nrom.rs allocates 8 KiB of PRG-RAM
unconditionally so accesses "don't fall off the edge", which is the iNES-era
emulator default the wiki names as a problem in its own words — and it lists
games that break on the WRAM answer, Low G Man and Battletoads & Double
Dragon among them.
Three things this establishes, in order of how much they matter:
- The ladder can catch the oracle. That was asserted when the programme was planned and is now demonstrated, which is a different kind of claim.
- The correct response was to record, not to fix. Changing it alters
shipped behaviour on every iNES-header NROM cartridge and needs the NES 2.0
WRAM-size field, the per-game database and the full accuracy battery. It is
in
accuracy-ledger.mdwith its citation. - The gate ROM was narrowed rather than the DUT bent.
busopen045reads$4020-$5FFFand not$6000-$7FFF, because a gate that fails for the oracle's limitation rather than the DUT's teaches the wrong lesson and eventually gets switched off.
The two risks that had to be settled before any RTL¶
Both were settled in v2.4.3, and both were answered by evidence that contradicted what the plan assumed. That is the point of running the experiment rather than reasoning about it.
Risk 1 — the sys/ licence, which inverted its own hedge¶
The plan required this before any RTL is written, because relicensing after ten
thousand lines exist is precisely the failure
docs/originality-and-provenance.md documents. Every file under
Template_MiSTer's sys/ was read and classified by its own grant, with comment
markers stripped and whitespace collapsed before matching.
| Classification | Files |
|---|---|
| GPL-2.0-or-later | 9 |
| GPL-3.0-or-later | 4 |
| GPL by reference, no version stated | 4 |
| Copyright, no grant | 4 |
| No header | 36 |
| GPL-2.0-only | 0 |
| 57 total |
The hedge was that a GPL-2.0-only file would force the RTL down to
GPL-2.0-or-later. There is no such file, and the binding constraint runs the
other way: ddr_svc.sv, hps_io.sv, scandoubler.v and sd_card.sv are
GPL-3.0-or-later, and hps_io.sv is not optional — it is how a core receives
a ROM from the HPS and how the OSD reaches it. GPL-2.0-or-later may be combined
into a GPL-3 work because "or later" permits the upgrade; GPL-3.0-or-later cannot
be reduced to GPL-2.
The combined bitstream must be GPL-3.0-or-later, which is already RustyNES's licence. No relicensing is needed.
Risk 4 — the Quartus subset, fitted rather than read¶
Quartus 17.0.2's SystemVerilog subset is materially narrower than Verilator's, and a construct rejected after ten thousand lines exist invalidates not one file but the style every file was written in. The acceptance criterion was never "it compiles" — it was a fitted netlist and its resource report, because only the second catches a 2 KiB memory that became 16,384 flip-flops, and Quartus does that silently.
Quartus Prime Lite 17.0.2 Build 602, device 5CSEBA6U23I7, defaults throughout:
Analysis & Synthesis was successful. 0 errors, 0 warnings
Fitter was successful. 0 errors, 4 warnings
Total block memory bits ; 16,384 / 5,662,720 ( < 1 % )
M10K blocks ; 2 / 553
Total registers ; 29
Logic utilization (ALMs) ; 17 / 41,910 ( < 1 % )
29 registers, not 16,413. The inference style that worked is a
logic [7:0] mem [0:2047] with one synchronous read port on a registered
address, one synchronous write port, and no asynchronous read — an async read
is the usual reason an array becomes registers. Quartus produced a Simple Dual
Port ALTSYNCRAM unaided, populated its MIF from the initial block (which is how
a boot ROM lands inside the block), and one-hot encoded the enum as a state
machine.
This is now the required style, and every commit adding a memory quotes its resource line — because the failure is silent in isolation and only becomes unfittable in aggregate.
Nine constructs are fitted. Plain case, priority case and $bits are
deliberately left documented: the kitchen sink does not exercise them, and
"near-certainly fine" is the phrase the subset policy exists to refuse.
Extending the subset means extending that module and re-fitting.
The four fitter warnings are 58 unconstrained pins and a missing .sdc, in a
module with no pinout and no timing constraints. Timing closure is a rung-6
question.
Rung 1 — the 6502, and where it has actually got to¶
Nine opcode groups have closed. 2115 records across nine ROMs on rung 1,
4537 cycles on the per-cycle bus gate, and 27,388 cycles of nestest --
all matching the oracle, and pc agreeing on 3551 of 3551 cycles.
Two of v2.5.0's stated gates do not close, and neither is a defect. nestest
0-diff over the whole run and per-cycle equality over a 5 M-cycle window both
need a PPU: nestest reads $2002 at cycle 27396, both sides address it, and
only the data differs -- a missing peripheral, rung 3 by design. The
interrupt-injection sweep has no oracle-side stimulus at all, so the pins, the
hijack and delayed-I are implemented and not oracle-verified; BRK is,
because a software interrupt needs no pin. docs/adr/0038-cosim-interrupt-injection-api.md
records the decision that would unblock the sweep, and the two preconditions
that void it. The RTL lives in the sibling
repository; RustyNES_MiSTer/docs/rung1-6502.md is its detailed record.
| release | scope | records |
|---|---|---|
| v2.4.4 "Ignition" | the eight-cycle reset and the single-byte implied group | 147 |
| v2.4.5 "Compass" | immediate / zero page / absolute; loads, stores, all eight branches | 140 |
| v2.4.6 "Abacus" | three indexed modes with the page-cross penalty; ADC/SBC; the compares |
286 |
| v2.4.7 "Keystone" | the stack group, JSR/RTS/RTI, JMP and its page-boundary bug |
179 |
| v2.4.8 "Palimpsest" | read-modify-write: ASL/LSR/ROL/ROR and INC/DEC, accumulator plus four memory modes |
358 |
| v2.4.9 "Plumbline II" | the logical group; the undocumented opcodes; and rung 2's bus half | 236 + 317 |
The earlier ROMs are re-run on every change, which is how the v2.4.5 datapath rewrite was shown not to regress v2.4.4.
The counts are measured -- each one from running cpu-gate against a freshly
exported golden -- rather than carried forward from the release that introduced
the ROM. opgroup1 closed v2.4.4 over a 0..64 cycle window and was
reported then as 29 records; v2.4.5 widened every ROM's window to catch the
addressing modes, and the same ROM now yields 147. Both numbers are true of
different windows, which is exactly why a table mixing them would not add up --
the totals here are all measured under the current windows.
What the rung has established beyond the opcodes¶
The DUT is the third writer of CpuBootTrace, after the oracle itself and
scripts/mesen2_cpu_boot_trace.lua. cpu_boot_trace_diff reads it with no
modification, and --skip-fields already existed -- so the entire rung has
needed no oracle-side change. That is the payoff of replay-rather-than-
lockstep stated concretely: the comparison tooling does not know one side is
hardware.
The oracle corrected our own specification. docs/cpu-6502.md said reset was
a 7-cycle sequence in one section and 8 in another. An independent implementation
written from that document implemented seven and diverged on its first record.
Reset is eight; the document is fixed. A second implementation built from a spec
is a way of testing that spec, and this is what it found on day one.
Three tests read correctly and verified nothing, each found by mutation and not by reading:
TXSafterTSX-- a wrongly-flaggingTXScomputes exactly the flagsTSXhad already left, so the wrong answer coincided with the right one.- A store and load in the same addressing mode -- self-consistent under any address mutation, so it tests round-tripping rather than addressing.
- A read of RAM the program had not written -- the oracle powers on with deterministic seeded work RAM while a flat-memory testbench starts at zero, so the divergence had nothing to do with the CPU.
Designing each case so the wrong answer differs from the right one is now the default rather than a correction.
What CI does and does not check¶
The sibling repository's cpu-smoke builds the core and runs the ROMs to
completion, and its workflow step name says it is not the accuracy gate --
because the oracle's goldens are not vendored there. They are reproducible from a
pinned oracle commit, and a second copy would be a drifting copy.
The accuracy comparison is make -C tb cpu-gate GOLDEN=.... Automating it needs
golden fetching from a pinned commit, which is not built yet and is not
pretended to be. That is the largest remaining hole in this rung's
infrastructure and it is named here rather than left to be discovered.
Still open¶
The gap below is CLOSED as of v2.4.9. make -C tb cpu-bus-gate catches both
mutations, and the section is kept rather than deleted because the reasoning is
what makes the gate's scope legible.
Read-modify-write closed in v2.4.8 -- but its double write did not, in the
sense that nothing at rung 1 verifies it. Two mutations (skip the dummy write; emit
the modified value instead of the old one) both come back NOT CAUGHT,
because neither changes a register, a flag, the final memory contents or the
cycle count, and those are the only things CpuBootTrace carries.
That is scoped to v2.4.9, beside the undocumented opcodes, rather than left
to v2.5.0: the bus half of rung 2 needs no new RTL. Observable already exists
on both sides with a byte-identical encoding, the oracle already emits it as
.obs.bin, and cpu6502 already exposes its whole bus. Wiring a writer produced
7 divergences across 793 cycles on a program rung 1 scores 358/358 -- two
causes, one harness fidelity (the testbench zeroes RAM the oracle seeds, so every
dummy read of unwritten memory diverges) and one a genuine per-cycle access
difference where instruction boundaries still agree.
Historical, as of v2.4.9. The two paragraphs below were true when written and are both superseded: the undocumented opcodes landed in v2.4.9, rung 2's bus half is live, and nestest now runs to 27,388 cycles. Kept rather than deleted because the reasoning still explains why the rung was scoped that way -- but the authoritative status is the section at the top of this file.
The undocumented opcodes are not delivered and also move to v2.4.9.
Rung 1's own gate -- nestest 0-diff over >= 8000 instructions -- is not met yet: the five ROMs are hand-built opcode groups, not nestest. Rung 2, the per-cycle bus and interrupt comparison, has not begun.
Rung 1 gets an independent oracle (v2.6.3)¶
The whole rung above was measured against ROMs written in this project, and the section before this one says exactly what that cannot cover. That limitation is now partly lifted.
blargg's instr_test-v5/rom_singles — sixteen third-party ROMs, ~2.68 M cycles
each, predating this programme by two decades and between them exercising all
256 opcodes — runs as a standing section of the sibling's regress.sh, compared
on rung 2's per-cycle bus surface. 16 of 16 exact, taking the suite from 50
gates to 66.
It is the first independent oracle rung 1 has had, and it earned that description immediately by finding three defects the self-written corpus had missed, none of them in the undocumented opcodes the battery was run to validate:
| ROM | defect |
|---|---|
06-absolute |
RRA fed its ADC stage the carry from before the instruction rather than the one the rotate produced |
08-ind_x |
the 8-cycle indirect RMW forms addressed the indexed target during their pointer fetch cycles |
03-immediate |
the PPU I/O-bus latch never decayed — a 2C02 defect, reached from a CPU ROM, three rungs after rung 3 closed |
The RRA finding is the one worth carrying forward, because of what it says
about compare surfaces rather than about the 6502. The instruction's own bus
trace was identical on both sides — read $FF, dummy-write $FF, write
$7F — and the divergence appeared nine cycles later in the STA that spilled
the accumulator, off by exactly one. A gate on the memory side of
read-modify-write would have passed it, and so would this rung's own
register-boundary trace had the next instruction not stored the accumulator
straight away.
Two hooks were needed to run third-party ROMs at all, and both are about not
transcribing a number: the window comes from each golden's own manifest rather
than a CYCLES_<rom> line, and the board's $6000-$7FFF work RAM is presented
per-ROM (blargg reports through it) rather than by default — NROM has none, which
is the oracle defect recorded above.
Rung 5 — the console, and the master-clock substrate¶
NROM, the work RAM, the CPU bus, the controller ports and DMC DMA are landed and
gated in the sibling; nes_top assembles them, carries no observation ports,
and as of v2.6.3 divides one 21.477272 MHz master clock rather than taking
its clock enables from the testbench.
The divider is this repository's own substrate, in SystemVerilog¶
The obvious shape — a modulo-CPU_DIV phase counter with the dot at
phase[1:0] == 3 — looks equivalent on NTSC and is a dead end. PAL is 16 master
clocks per CPU cycle and 5 per dot, i.e. 3.2 dots per CPU cycle, and no
counter that restarts each CPU cycle can hold a fractional ratio.
Bus::run_ppu_to does not do that. It keeps master_clock and ppu_clock as
independent accumulators in master-clock units and emits a dot whenever
ppu_clock + ppu_divider <= master_clock, letting dot boundaries drift across
the CPU cycle — which is exactly what PAL does. The DUT copies that structure, so
retargeting is a parameter change (NTSC 12/4, PAL 16/5, Dendy 15/5).
Two constants come from Cpu rather than from a sweep. read_split(12) is
(5, 7) and write_split(12) is (7, 5) — CPU_DIV/2 ∓ PPU_OFFSET — with the PPU
run to that point minus PPU_OFFSET. On NTSC a read observes at master clock 5
and a write commits at 7, and both fall inside the same dot, which is why the
DUT's earlier read/write placement sweep measured them onto one dot: this model
predicted it. PPU_OFFSET is also applied as a real phase offset between the
accumulators, so a cycle's last dot commits before the CPU rather than on the
same edge.
What it found: an enable that is constant is not an enable¶
The DUT's testbench used to tie ce high and pulse clk once per CPU cycle. The
clock was doing the gating the enable was supposed to do, so any always_ff
not gated by ce was correct only by accident — under a real master clock each
fires twelve times. Four sites, two of them previously unknown:
| site | symptom |
|---|---|
| PPU CPU-register block (v2.5.7) | a held address latched twelve times per access |
| open-bus decay reload | refresh never observed; $2002 read $00 for $1F |
| DMC DMA acknowledge | the sample pointer advanced by twelve per byte; 91% of cycles diverged |
| frame-counter IRQ set points | /IRQ rose eleven master clocks early; the CPU took the interrupt one instruction sooner |
The last was caught by blargg's 08.irq_timing — third-party, so an independent
oracle rather than our own trace agreeing with itself.
A fix that worked, and was rejected¶
Delaying the APU's /IRQ by one cycle in nes_top also produced 66 of 66, and
is indistinguishable from the real fix by gate result. It was not adopted:
Cpu::handle_interrupts samples IRQ at phi2 into mc_run_irq and dispatches on
mc_prev_run_irq — the one-cycle register this codebase calls
"second-to-last-cycle recognition" — and the DUT's cpu6502.sv already
implements exactly that, correctly gated. A second delay outside it would have
cancelled an APU-side error rather than corrected it.
This is the general hazard of an oracle-defined compare surface, stated once so rungs 6 and 7 inherit it: a fix that greens the gate is not evidence the fix is right. Continuing to look for a cause after the symptom cleared is the only thing that separated the two here.
Where the three-way disagreements are recorded¶
The sibling's docs/oracle-vs-documentation.md is the umbrella ledger: per
subsystem, where the DUT, this emulator and the public documentation differed,
which won, and why — using the same six categories as the APU chapter that
preceded it. It carries the one entry so far where this emulator was the one
that was wrong (NROM's PRG-RAM window), and the sharpest category-1 entry yet:
the PPU's open-bus decay deadline, where the documentation says 3-30 ms, this
emulator uses 558.7 ms, and the DUT's corpus forces >= 523.4 ms.
AccuracyCoin runs end to end, and the gate is a status vector¶
The full run now completes on the DUT — 17,868,316 cycles, where it previously halted early — which is what "first end-to-end AccuracyCoin run" in the plan's v2.6.3 row asked for.
The comparison is accuracycoin_status, on this side. It reads a work-RAM
dump, decodes it against the 146-entry catalog in
accuracy_coin_catalog.rs, and reports by test rather than by address. It
filters in both modes rather than dumping all 146 rows: given one dump it
prints the entries that are not a clean Pass, and given two it prints only
the entries where they disagree. The vector is decoded in full either way --
what is filtered is the output, not the comparison. First
measurement: 137 of 146 entries agree, 9 differ, six of those sharing one
failure code — five SH-group stores and Open Bus — which reads as one shared
address-bus cause rather than six independent defects.
Producing the vector is v2.6.3's deliverable. Making the two agree is v2.6.4, and the plan says so in its own acceptance row.
The "six sharing one failure code" reading was wrong, and v2.6.4 measured why.
AccuracyCoin's TEST_Fail reports (ErrorCode << 2) | 2 and the runner sets
ErrorCode to 1 before every test routine, so the code is an index within
one routine: Open Bus's code 7 is its own seventh assertion and SHA (abs),Y's
code 7 is that routine's seventh. Two entries sharing a code share nothing. What
actually closed the five SH entries was the SH group's RDY-conditional store
and its addressing-mode-dependent dummy cycle — a real shared cause, identified
from the opcodes. Open Bus was untouched by that work and remained, which
should have refuted the shape argument at the time.
And the number underneath the agreement. Once all nine closed, the vector
reported identical entry for entry across all 146 — with 58 of those entries
NotRun on both sides. The comparator was right; the run window was short.
Broken down by suite, 600 frames reaches the CPU catalog and stops partway
through CPU Interrupts, so the run asked the DUT nothing about the APU, PPU,
sprite-evaluation or PPU-misc suites — the chips rungs 3 and 4 exist for.
Measured rather than estimated, 4500 frames reaches all 146 (134,012,761
cycles), and that is the golden the gate now uses. A pass count is a claim about
what ran, and what ran has to be measured separately.
Decoding the codes properly is also what closed two of the last three, in
v2.6.4: Open Bus (a $4015 read does not drive the data bus, and its D5 is
open bus) and Interrupt flag latency (the interrupt poll is the second-to-last
cycle, and branches poll before cycles 2 and 4 but never before 3). Both rules
are stated by the test ROM's own comments and by neither of the nesdev pages the
implementation was written from. NMI Overlap BRK is carried to v2.6.5 with its
disagreement measured per sweep step rather than as one byte — see the sibling's
docs/rung5-accuracycoin.md.
Two properties are worth recording, because both are about what the comparison refuses rather than what it reports:
- It is not a RAM byte-compare, deliberately. Comparing 2 KiB of work RAM answers a different question and answers it wrongly in both directions: it reports scratch bytes — a stack slot, a loop counter, a result the ROM is about to overwrite — as failures, and it reports two runs that never started the battery as a pass, because two idle title screens have identical RAM.
- An all-
NotRunvector is refused with a non-zero exit. That case — two vectors agreeing on 146 entries of nothing — is precisely the shape of the vacuous status-address assertion v2.6.2 found in the NTSC blargg suite, which reported 11/11 for five minor releases while asserting nothing. A comparison that cannot distinguish agreed from never ran is not a gate.
Reaching the run cost two false passes before a real one: AccuracyCoin idles
on its title screen until START is pressed, and only the framebuffer half of the
export had a guard that refused an idle capture. The manifest now records
press_start — as A:B, or the literal none — because a controller press
changes what the ROM executes, and a manifest omitting it describes an idle
title screen and an 88-result battery identically.
Rung 4 — the 2A03, and the audit it prompted¶
Rung 4 opened at v2.5.9 with the two pulse channels and the frame counter, and v2.6.0 "Assay" adds the triangle, the noise channel and the sweep unit. Eleven gate ROMs, 35 of 35 mutations CAUGHT, and 30 gates green across rungs 1–4.
Detail lives in the sibling repository (docs/rung4-apu.md), but two things
belong here because they are about the programme, not about the APU.
The oracle is an emulator, and rung 4 is where that stopped being abstract¶
Every rung-4 gate compares the DUT against RustyNES. A shared error between the two is therefore invisible by construction: the DUT is tuned until it agrees, and agreement with a wrong reference is indistinguishable from correctness.
docs/apu-oracle-vs-documentation.md in the sibling repo is the response —
a standing ledger of every place the DUT follows the oracle rather than the NESdev
wiki, sorted by risk, each with the documentation text it is measured against and
the independent check that would adjudicate it. Its maintenance rule is the load-
bearing part: an item closes only when a gate exercises it and a mutation
against it is CAUGHT.
The independent check was run at v2.6.0 for the first time: the oracle passes blargg's APU battery 29/29. That is recorded per item rather than as a headline, because it means different things in different places — for two items it moved suspicion off the oracle and onto the RTL, and for one there is no adjudicating ROM at all, which is itself the finding.
Two errors that were cancelling, and what that says about mutation testing¶
The $4017 reset delay had been keyed on the mode bit, which the wiki never
mentions. It was exact only in combination with a second constant held one tick
off its documented value; either correction alone costs 2 cycles, in opposite
directions. The fitted rule was not merely unfalsified by the stimulus — it was
load-bearing for a second error, which is why it survived both a mutation
catalog and a documentation audit that looked directly at it.
This is the third time the programme has found two errors cancelling (v2.5.7's
PPU_LEAD, v2.6.0's own observation point), and the first where one propped up
the other rather than merely coinciding with it. A green mutation catalog does
not establish that constants are individually right — only that the combination
in the tree is not detectably wrong on the current stimulus. Cross-checking each
constant against documentation is a separate activity, and rung 4 is where the
programme learned to do it.