feat(v2.4.1): "Fabric" — RustyNES as a co-simulation oracle for an FPGA device-under-test - #429
Conversation
…der-test Opens the v2.4.1 - v2.5.0 "Fabric" line (ADR 0037): a new NES core written in SystemVerilog from public hardware documentation, in a sibling RustyNES_MiSTer repository, verified against this emulator. This commit lands the half that lives here -- the boundary between the two -- plus the decision record, the execution plan, and the research archive behind them. RustyNES is not being ported to FPGA and cannot be. A MiSTer core is SystemVerilog compiled by Quartus 17.0.2 into a Cyclone V bitstream; Rust does not become a bitstream, and high-level synthesis of a cycle-accurate emulator's control flow does not produce usable hardware. What is buildable is a new implementation with RustyNES as its verification oracle -- the one role it is uniquely equipped for, and the reason to attempt this here rather than elsewhere. The reference firewall extends to HDL accordingly: NES_MiSTer and fpganes rtl/ are strict black boxes, instantiable as opaque modules to compare outputs, never readable as source. WHAT LANDS crates/rustynes-cosim is a pure wrapper. It adds no core API, changes no core behaviour, sits outside workspace default-members (verified through cargo metadata, not assumed), and exposes two surfaces: a narrow C ABI a Verilator testbench links, and the safe Rust API a golden-export binary uses. The nes_golden_export CLI emits five formats -- the CpuBootTrace binary, the per-cycle IRQ/bus CSV, a pre-palette u16 index framebuffer, the 2 KiB work RAM the AccuracyCoin RAM decoder reads, and a provenance manifest. Two design choices are load-bearing and recorded so they are not re-litigated. Replay rather than lockstep, because Nes exposes run_frame() and step_instruction() and nothing finer -- cycle-lockstep would mean new core API on the hot path, and it would gain nothing, since the determinism contract already makes a pre-recorded trace exactly the trace a lockstep run produces. And the framebuffer is exported pre-palette, so a palette difference cannot masquerade as a rendering difference: the failure mode v2.3.8 "Parallax" was built to prevent. TWO FINDINGS THE CRATE WAS NOT LOOKING FOR No CI invocation had ever enabled cpu-boot-trace or irq-timing-trace for clippy, so those two rustynes-core modules had never passed the lint gate. Turning them on surfaced six pre-existing errors -- four missing_const_for_fn, one manual_is_multiple_of, one collapsible_if -- all fixed here. The workspace gate reads as exhaustive and is not: --workspace --all-targets covers each crate's DEFAULT feature set, so a cfg-gated module is invisible to it. All six sites are behind default-off features, so the shipped core is untouched by construction; the collapsed if keeps its is_some() guard as the first arm of a let-chain, because take() clears trace_a12_latest and short-circuiting is what keeps that from running when tracing is off. This crate therefore enables both features UNCONDITIONALLY rather than re-exposing them as its own optional features. A build without them would compile, link, run, and export empty goldens -- an absence of signal that reads exactly like agreement. Mandatory turns that into a compile error. And the first run_frame() after power-on advances zero cycles. 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 call consumes the latch and returns having stepped nothing. 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 -- but a bare loop would have emitted an (n-1)-frame golden under a manifest claiming n, a provenance record wrong in the one direction that matters, since a DUT compared against it would be off by a frame for reasons nothing in the record explains. Oracle::advance_frames gates on the frame counter instead, with a jam bail-out, and the manifest records requested and actual separately because they can legitimately differ. VERIFICATION Three new assertions, each proven by mutation before being trusted: counting calls instead of frames is caught, an off-by-one target is caught, and removing the jam bail-out is caught as a HANG rather than a failure -- which is why the mutation harness needed a timeout to register it at all. rustynes-core changed, so the accuracy gates are verified rather than asserted: AccuracyCoin 141/141 (100.00%, RAM decoder -- the framebuffer decoder reports 120 and is known-buggy) and nestest 0-diff. Workspace 2226 passed across 128 suites, 0 failed. fmt, workspace clippy, the four frontend feature combos, both wasm32 combinations, the no_std thumbv7em build and rustdoc with warnings as errors all pass. SCOPE, STATED RATHER THAN DISCOVERED v2.5.0 is scoped to "the 6502 rung closes" -- the harness plus a cycle-exact 6502, gated on nestest 0-diff and per-cycle bus equality. A from-scratch cycle-accurate NES core is 7-13 months FTE against a two-to-four-week window at demonstrated cadence, so PPU, APU and MiSTer integration become the v2.6-v2.9 programme. Two risks are accepted in writing: NES_MiSTer already scores 121/125 on AccuracyCoin where real Famicom AV hardware also scores ~121/125, so there is no published accuracy headroom and the core may be declined as a duplicate; and the oracle can be wrong, since 141/141 is not "matches silicon", so every rung is labelled by whether it has an independent oracle. Docs: ADR 0037, docs/mister.md, to-dos/plans/v2.5.0-fabric-plan.md, and the research archive at to-dos/plans/research/. AGENTS.md gains the HDL firewall extension and three operating notes. Addresses the "Fabric" programme opening; no issue is finished by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The "Fabric" ladder's rung 0 is stated as: feed RustyNES's own golden back in as if it were the device-under-test and get zero divergences. Without it, every later red result is ambiguous between "the RTL is wrong" and "my writer packs a field wrong" -- and early on the second is far likelier. Three tests, and the second and third exist because the first alone proves less than it appears to. The self-diff asserts two independent exports of the same ROM and seed 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 the boundary this crate exposes: if it fails, a pre-generated golden is not the trace a lockstep run would have produced, and replay-as-oracle is unsound rather than merely inconvenient. The corruption test exists because a comparator that always reports agreement passes a self-diff trivially. One flipped bit in the middle of the trace is the smallest divergence an RTL bug could plausibly produce, and it must not compare equal. The frame-count test checks the count WITHOUT trusting the counter that produced it: five frames must be five NTSC frames' worth of CPU cycles, and it must take six run_frame() calls to get there, because the first after power-on is swallowed by the frame_complete latch the reset sequence leaves set. Done in integer half-cycles rather than floating point -- the whole job is an arithmetic check, and doing it in f64 would have meant two lossy casts and a lint suppression to verify five frames. VERIFIED BY HAND FIRST, THEN BY MUTATION Exported against the committed mmc1_a12 ROM and checked three ways, because a manifest that agrees with itself is not evidence. The recorded rom_sha256 matches an independent sha256sum. run_frame_calls = 6 for frames_actual = 5 makes the power-on latch visible in the record rather than hidden by it. And 148905 cycles divided by 29780.5 is exactly 5.000 frames, where a bare loop gives 4.0. The real cpu_boot_trace_diff CLI reports "All 5464 aligned records match" on the self-diff and exits 0; the same CLI against a one-bit corruption reports the divergence at cycle 561, PC $C419, naming the field and both values. So the zero is a signal from a tool that can tell, not from one that cannot. Then mutated: advance_frames reverted to the bare `while calls < frames` loop that motivated it. Caught, with the magnitude quantified -- 119123 cycles against an expected 148902, short by 59559 half-cycles, which is exactly one frame. Gates: fmt and clippy clean for the crate; 8 unit plus 3 integration tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The spec described rung 0's acceptance criterion as something to build. It is built for the half that lives in this repository, so the document should say what exists rather than what is planned -- including that the zero was checked against the real cpu_boot_trace_diff CLI and shown to be able to report non-zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…not append The ceremony rules already record that this reviewer posts as a plain issue comment, invisible to both reviewThreads and reviews[].body. There is a sharper hazard on top of that, observed on PR #428. Round 1 posted at 13:37:57Z and round 2 at 14:21:35Z. Afterwards the issue-comments endpoint returned exactly ONE bot comment, created_at equal to updated_at equal to 14:21:35Z. The first was gone -- not edited, since the timestamps would differ, and not appended to. Deleted and replaced. CodeRabbit and Copilot threads persist and can be resolved, so an unread finding stays visible. An Antigravity finding does not, and nothing on the PR indicates a round ever existed. A clean comment list is therefore not evidence that nothing was raised. Both rounds on that PR were blocking and correct, one of them a data-loss defect, so the cost of missing a round is not hypothetical. The rule that follows: read the comment before every push rather than after, and quote its findings into the reply, since the reply persists and the original does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…ere it is not The note written when the behaviour was discovered described it as current. v2.4.0 fixed it, so the note now records what happened, what replaced it, and the two places the fix does not yet apply. The mechanism is worth keeping in full rather than trimming to "fixed": it is the reason the bot ceremony has to be run before a push rather than after, and that rule still binds on the four sibling repos until their own PRs land. Two details added that the original note could not have had. Rounds are delimited by an HTML-comment sentinel rather than the `<details>` tag, because a review body legitimately contains `<details>` blocks and matching the tag cut inside a round -- found in review one commit after adopting markers elsewhere in the same file for exactly that reason. And the default-branch rule has a corollary that bit once: the workflow YAML comes from the PR branch while its checkout fetches the default branch, so a change adding a script file breaks its own PR unless the workflow half tolerates both script sets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Antigravity review (Gemini via Ultra)Adds Blocking issues
Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-20 19:03 UTCAntigravity review (Gemini via Ultra)Adds Blocking issues
Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-20 18:28 UTCAntigravity review (Gemini via Ultra)This PR introduces the Blocking issues
Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-20 18:23 UTCAntigravity review (Gemini via Ultra)This PR adds a co-simulation crate to generate and export golden trace vectors for verifying an independent HDL NES core against RustyNES. Blocking issues
Suggestions
NitpicksNone found. Automated first-pass review by |
CI's `cargo test --workspace --release --features test-roms` failed to compile the `null_dut_self_diff` target with nineteen errors of the form: error: the crate `sha2` requires panic strategy `abort` which is incompatible with this crate's strategy of `unwind` This crate declares `crate-type = ["rlib", "staticlib", "cdylib"]` because a Verilator testbench links the C ABI. Under `--release` the workspace profile sets `panic = "abort"`, so the dependency graph is built with that strategy -- and an INTEGRATION test in `tests/` is a separate binary that links the rlib and needs `unwind`. The two cannot share those dependencies. The three checks now live in the lib's `#[cfg(test)]` module beside the eight that were already there. Nothing is lost: they drive the crate's public API exactly as an external consumer would, which is the property that made them integration tests in the first place. The reason is recorded at the site, because the next reader will want to move them back. `rustynes-libretro` is the same shape and has never hit this, and comparing the two is what identified the mechanism rather than the symptom: it declares `cdylib` and `staticlib` with NO `rlib`, and keeps its tests `#[cfg(test)]` in `src/`. That is the working configuration for a crate in this workspace that exports a C ABI. WHY NO LOCAL GATE CAUGHT IT It compiles clean in debug. My gate script runs `cargo test` without `--release`, and `cargo test --release -p rustynes-cosim` alone also passes -- the failure needs the workspace-wide release build with the harness features, which is precisely what the CI job runs and what I had not run locally. That is the same shape as the two other misses in this line: a gate that exists but does not reach the configuration in question. The wasm32 combinations were one, `#[cfg(windows)]` code never being type-checked on Linux was another, and this is a third -- release-profile linking of a multi-crate-type crate. 11 tests pass in the crate. Verified where it counts rather than by inference: under the exact CI invocation (`cargo test --workspace --release --features test-roms`), `rustynes-cosim` now compiles with ZERO panic-strategy errors -- the crate that previously produced nineteen. The rest of that build is the test harness's fat-LTO link and is left to CI, which is the authoritative gate for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…allowed errors
Three of the four blocking findings from review, all confirmed before acting and
one of them a corrupted-output bug rather than a style point.
A DOT IN THE ROM NAME TRUNCATED EVERY GOLDEN
`Path::with_extension` replaces everything after the LAST dot. A ROM named
`Super Mario Bros. 3.nes` has a stem of `Super Mario Bros. 3`, so
`with_extension("ram.bin")` produced `Super Mario Bros.ram.bin` -- the frame
number silently eaten, and every one of the five goldens landing under a name
that is not the one the manifest describes.
Verified rather than reasoned about: a two-line program prints exactly that.
Dots are common in NES filenames, so this would have corrupted real golden sets
rather than being a theoretical edge. `suffixed()` appends to the `OsString`
instead. The test asserts the correct name AND asserts that it differs from
`with_extension`'s, so a future "simplify this" is caught rather than silently
reintroducing it.
TWO SWALLOWED ERRORS
`sha256_hex` discarded a `fmt::Result` through `let _ = write!`. It cannot fail
into a `String`, but the project's rule is about the shape, not the odds, and the
hex is now built by hand with no fallible call to discard.
Review also suggested `format!("{:x}", Sha256::digest(data))`. That does not
compile: sha2 0.11 returns `hybrid_array::Array<u8, _>`, which does not implement
`LowerHex` -- `the trait bound Array<u8, ...>: LowerHex is not satisfied`, checked
against a scratch crate rather than assumed. The digests are now pinned against
the known SHA-256 of `""` and `"abc"`, which is an independent oracle rather than
our own output.
The C ABI returned a flat `-3` for every write failure. A full disk, a read-only
directory and a permission denial were indistinguishable to the testbench -- and
the testbench is the only thing that sees this, so discarding the cause at the
boundary is the one place it cannot be recovered later. Codes at or below `-100`
are now `-(100 + errno)`, with `-1`..`-9` still meaning "the call was malformed"
and `-3` reserved for an error carrying no `raw_os_error`.
STILL OPEN, DELIBERATELY NOT FIXED HERE
The fourth finding -- workspace feature unification -- is REAL and confirmed. A
`cargo build --workspace` builds `rustynes-core` ONCE with the union of features,
and that union now includes `cpu-boot-trace` and `irq-timing-trace`:
['cpu-boot-trace', 'debug-hooks', 'default', 'hd-pack', 'irq-timing-trace', 'std']
measured through `--message-format=json` rather than inferred. It does not affect
the shipped binary (`-p rustynes-frontend` never selects this crate) and it does
not affect behaviour (the features are output-only; AccuracyCoin is 141/141 in a
workspace run), but "the emulation core is untouched" is imprecise as written for
workspace-wide builds.
Every fix for it trades away something real -- excluding the crate from the
workspace costs `--workspace` test coverage of its 14 tests, and making the trace
features non-default costs the compile-time guarantee that a misconfigured build
cannot export empty goldens. That is a maintainer's call about which guarantee
matters more, not one to make silently inside a review round, so it is carried
rather than guessed at.
Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32
combinations, the no_std thumbv7em build, rustdoc with warnings as errors, and
14 tests in the crate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…ery tests the shipped scheduler `rustynes-cosim` 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. Measured through `--message-format=json` rather than inferred: before: ['cpu-boot-trace', 'debug-hooks', 'default', 'hd-pack', 'irq-timing-trace', 'std'] after: ['debug-hooks', 'default', 'hd-pack', 'std'] THE PERCENTAGE IS NOT THE REASON The instrumented build costs +1.24% / +1.39% / +1.89% across the three `full_frame` benches -- BELOW this project's own 3% adoption bar. Measured and recorded precisely because it shows performance was never the argument, and a reviewer's "severely degrading emulator performance" overstated it. The reason is that `irq-timing-trace` selects a DIFFERENT `for sub_dot in 0..3` loop in `Bus::tick_one_cpu_cycle` -- there are two, under opposite `cfg`s. So CI's `cargo test --workspace --release --features test-roms`, the accuracy battery, was validating a scheduler no user runs. That is the v2.3.4 defect repeating, where the coverage harness tested a load path no user runs, and it is wrong at any percentage. It does not affect the shipped binary: `cargo build --release -p rustynes-frontend` never selects this crate. Nor the perf gate, which is `cargo bench -p rustynes-core`. Exactly one gate was compromised, and it was the one that matters most. WHAT EXCLUSION COSTS, AND WHY NEITHER COST IS LEFT ON TRUST An excluded package cannot use `field.workspace = true`, so version, edition, rust-version, license, repository and both lint tables are duplicated. Duplication nothing checks is duplication that drifts -- and a crate quietly holding itself to weaker lints than the rest of the project is the kind of erosion nobody notices until it matters. `cosim_manifest_audit.rs` asserts every duplicated value still equals the workspace's, AND that the crate is still in `exclude` -- so re-adding it to `members` fails a test rather than silently restoring the unified build. Four mutations, all caught: re-added to members, a version drift, a weakened lint, an edition drift. `cargo fmt --all`, `clippy --workspace` and `test --workspace` no longer reach it, so CI gains three explicit steps naming its manifest. Without them the crate would simply stop being checked and nothing would say so. THE CLIPPY STEP EARNED ITS PLACE ON ITS FIRST RUN It reported a `must_use_candidate` on `Oracle::nes()` that `cargo clippy --workspace` had never surfaced. I do not have a confident explanation for why the standalone invocation sees it and the workspace one did not, and I would rather record that than invent one -- the useful fact is that the explicit step is not ceremony, it covers a real gap. VERIFIED The full gate matrix (fmt, workspace clippy, four frontend feature combos, both wasm32 combinations, no_std thumbv7em, rustdoc with warnings as errors), plus the excluded crate's own fmt / clippy / 14 tests, plus AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff on the SHIPPED path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Feature unification — you were right, and the fix is exclusionVerified before acting, and the measurement is worth stating because it partly
The performance claim was overstated. The instrumented build costs The real problem was worse than performance. The crate is now in
The clippy step reported a The other three findingsFilename corruption — real, fixed. Discarded Swallowed One suggestion declinedlet-chains "verify they're stable in your MSRV". They are, and this is the AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff on the |
…#430) Promotes the accumulated CHANGELOG entries into a dated section, bumps the workspace version, and moves all 15 release anchors. Nothing else changes: no source file outside the two manifests is touched, so the shipped emulation core is the one that merged in #429. The release carries two versions, and that is not a naming convenience. v2.4.0 "Concordance" merged to main in #428 and was never tagged, so its entries were still sitting under [Unreleased] alongside v2.4.1's. Splitting them now would mean tagging v2.4.0 at a commit that main has already moved past, and publishing two releases minutes apart whose binaries are identical except for a version string. Instead the section states plainly that it carries both, and every entry belonging to the earlier one is marked (v2.4.0 item) so the attribution survives. Two entries belong to neither -- the release-anchor audit and the deferred-backlog sweep landed in #427, between the two -- and the preamble says so rather than letting "unmarked" silently mean v2.4.1. The version bump is deliberately part of this commit rather than an earlier one. Cargo.toml's own comment records why: release-auto.yml reads "workspace version has no matching tag" as "this is ready to release", so a bump that arrives early reaches main while the release does not exist and the workflow then fails closed on every push for want of release notes. Bumping here, with the CHANGELOG section and the release-notes override in the same change, is the only ordering that does not leave main red for a development window. Three mechanical consequences, each verified rather than assumed: - rustynes_libretro.info's display_version moves to v2.4.1, because libretro_info_audit.rs pins the LOCAL file against the workspace manifest. The cadence rule that lets display_version lag through a patch run applies to the UPSTREAM copy in libretro-super, which nothing here touches; the next upstream sync is still v2.5.0. - crates/rustynes-cosim/Cargo.toml carries its own version literal, because an excluded package cannot use `field.workspace = true`. cosim_manifest_audit.rs is what makes that duplication safe, and it is what would have caught this line being missed. - The CHANGELOG header is load-bearing, not decoration. release-auto.yml parses it for both the release-body fallback and the title codename, so the ` - <date> - "<Codename>" (<theme>)` shape is asserted by release_anchor_audit.rs rather than left to care. .github/release-notes/v2.4.1.md is a maintainer-authored override, so the published body is the narrative rather than the changelog. It discloses something the previous release's notes got wrong: v2.3.9's published body describes release_anchor_audit.rs, and `git cat-file -e v2.3.9:crates/rustynes-test-harness/tests/release_anchor_audit.rs` fails -- that work landed in #427, after the tag was cut. The audit exists because eight documents had reached six different answers about the current version; a release note describing work its own tag does not contain is the same failure one level up, so it is recorded here rather than quietly corrected. Anchors moved (15 across 10 documents, all named by the audit when it fails): README.md badge + Current Release, docs/STATUS.md, AGENTS.md x3 (the "What this is" block, the operating-notes bullet, and the never-claim-a-later-version guard), VERSION-PLAN.md header + its (current) table row, to-dos/ROADMAP.md, SUPPORT.md, SECURITY.md, ROADMAP.md x2, OVERVIEW.md x2, ARCHITECTURE.md. The VERSION-PLAN table gains a row for each of v2.4.0 and v2.4.1; the audit asserts exactly one row is marked (current), so a forgotten row fails loudly. Gates run on this tree, not inherited from the merge: fmt (workspace + the excluded crate), clippy --workspace --all-targets, the excluded crate's clippy, the four frontend feature combinations (scripting; scripting,hd-pack; retroachievements; full), BOTH wasm32 invocations, the no_std thumbv7em build, rustdoc with warnings as errors, cargo deny (advisories/bans/licenses/sources ok), actionlint, and markdownlint over every changed document. Tests: 126 workspace suites / 2222 passed / 0 failed, plus the excluded crate's 3 suites / 14 passed, which --workspace cannot reach. Accuracy re-run rather than reasoned about, since rustynes-core changed in both halves of what this tags: AccuracyCoin (RAM): pass rate = 100.00% over 141 assigned tests nestest_pc_c000_matches_golden_log ... ok The framebuffer decoder reports 120 and stays known-buggy; the RAM decoder is the authoritative one.
RustyNES as a co-simulation oracle for an FPGA device-under-test
Opens the v2.4.1 → v2.5.0 "Fabric" line (ADR 0037):
a new NES core written in SystemVerilog from public hardware documentation, in a
sibling
RustyNES_MiSTerrepository, verified against this emulator. This PRlands the half that lives here — the boundary between the two — plus the decision
record, the execution plan, and the research archive behind them.
RustyNES is not being ported to FPGA, and cannot be
A MiSTer core is SystemVerilog compiled by Quartus 17.0.2 into a Cyclone V
bitstream. Rust does not become a bitstream, and high-level synthesis of a
cycle-accurate emulator's control flow does not produce usable hardware. What is
buildable is a new implementation with RustyNES as its verification oracle —
the one role it is uniquely equipped for, and the reason to attempt this here.
The reference firewall extends to HDL accordingly:
NES_MiSTerandfpganesrtl/are strict black boxes, instantiable as opaque modules to compareoutputs, never readable as source.
What lands
crates/rustynes-cosimis a pure wrapper. No core API, no core behaviour change,outside
workspace.default-members(verified throughcargo metadata, notassumed). Two surfaces: a narrow C ABI a Verilator testbench links, and the safe
Rust API a golden-export binary uses.
nes_golden_exportemits five formats — theCpuBootTracebinary, the per-cycle IRQ/bus CSV, a pre-paletteu16indexframebuffer, the 2 KiB work RAM the AccuracyCoin RAM decoder reads, and a
provenance manifest.
Replay, not lockstep, because
Nesexposesrun_frame()andstep_instruction()and nothing finer — cycle-lockstep would mean new core API onthe hot path, and would gain nothing, since the determinism contract already makes
a pre-recorded trace exactly the trace a lockstep run produces.
The framebuffer is exported pre-palette on purpose: a palette difference must
not be able to masquerade as a rendering difference — the failure mode v2.3.8
"Parallax" was built to prevent.
Two findings the crate was not looking for
No CI invocation had ever enabled
cpu-boot-traceorirq-timing-traceforclippy, so those two
rustynes-coremodules had never passed the lint gate.Turning them on surfaced six pre-existing errors, all fixed.
--workspace --all-targetsreads as exhaustive and is not: it covers each crate's defaultfeature set, so a
cfg-gated module is invisible to it. This crate enables bothfeatures unconditionally — a build without them would export empty goldens,
an absence of signal that reads exactly like agreement.
The first
run_frame()after power-on advances zero cycles. The PPU isconstructed at dot 340 of the pre-render line, so the 7-cycle reset sequence ticks
past the frame wrap and leaves
frame_completelatched. Measured, not inferred:frame 0 advances the cycle counter by 0, frames 1..3 by ~29,780 each. Every other
caller runs thousands of frames so it is invisible to them — but a bare
for _ in 0..nloop would emit an (n−1)-frame golden under a manifest claiming n.Oracle::advance_framesgates on the frame counter, with a jam bail-out, andthe quirk is pinned by a test that names it.
The rung-0 gate, shown able to fail
tests/null_dut_self_diff.rsfeeds RustyNES's own golden back in as if it were theDUT and requires zero divergences — plus a one-bit corruption being caught
(a comparator that always agrees passes a self-diff trivially), and five frames
being five NTSC frames' worth of cycles in six
run_frame()calls.Verified against the real
cpu_boot_trace_diffCLI:All 5464 aligned records match, exit 0; and on a one-bit corruption it reports the divergence at cycle 561,PC
$C419, naming the field and both values. The recorded ROM SHA-256 matches anindependent
sha256sum, and 148905 cycles ÷ 29780.5 = exactly 5.000 frameswhere a bare loop gives 4.0.
Scope, stated rather than discovered
v2.5.0 is scoped to "the 6502 rung closes" — the harness plus a cycle-exact
6502, gated on nestest 0-diff and per-cycle bus equality. A from-scratch
cycle-accurate NES core is 7–13 months FTE against a two-to-four-week window at
demonstrated cadence, so PPU, APU and MiSTer integration become the v2.6–v2.9
programme.
Two risks accepted in writing:
NES_MiSTerscores 121/125 on AccuracyCoinwhere real Famicom AV hardware also scores ~121/125, so there is no published
accuracy headroom and the core may be declined as a duplicate (Retro Remake is
the planned fallback home); and the oracle can be wrong, since 141/141 is not
"matches silicon", so every rung is labelled by whether it has an independent
oracle.
Verification
AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff — verified,
not asserted, since
rustynes-corechanges. Workspace 2246 passed / 129 suites / 0 failed. Full clippy matrix, both wasm32 combinations,no_stdthumbv7em,rustdoc with warnings as errors. Three new assertions proven by mutation, including
one caught as a hang rather than a failure.
🤖 Generated with Claude Code
https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj