Skip to content

feat(v2.4.1): "Fabric" — RustyNES as a co-simulation oracle for an FPGA device-under-test - #429

Merged
doublegate merged 8 commits into
mainfrom
feat/v2.4.1-cosim
Aug 20, 2026
Merged

feat(v2.4.1): "Fabric" — RustyNES as a co-simulation oracle for an FPGA device-under-test#429
doublegate merged 8 commits into
mainfrom
feat/v2.4.1-cosim

Conversation

@doublegate

Copy link
Copy Markdown
Owner

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_MiSTer repository, verified against this emulator. This PR
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.

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. No core API, no core behaviour change,
outside workspace.default-members (verified through cargo metadata, not
assumed). Two surfaces: a narrow C ABI a Verilator testbench links, and the safe
Rust API a golden-export binary uses. nes_golden_export 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.

Replay, not lockstep, because Nes exposes run_frame() and
step_instruction() and nothing finer — cycle-lockstep would mean new core API on
the 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-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, all fixed. --workspace --all-targets reads as exhaustive and is not: it covers each crate's default
feature set, so a cfg-gated module is invisible to it. This crate enables both
features 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 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. 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..n loop would emit an (n−1)-frame golden under a manifest claiming n.
Oracle::advance_frames gates on the frame counter, with a jam bail-out, and
the quirk is pinned by a test that names it.

The rung-0 gate, shown able to fail

tests/null_dut_self_diff.rs feeds RustyNES's own golden back in as if it were the
DUT 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_diff CLI: 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 an
independent sha256sum, and 148905 cycles ÷ 29780.5 = exactly 5.000 frames
where 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_MiSTer 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 (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-core changes. Workspace 2246 passed / 129 suites / 0 failed. Full clippy matrix, both wasm32 combinations, no_std thumbv7em,
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

doublegate and others added 5 commits August 20, 2026 13:20
…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
Copilot AI lite review requested due to automatic review settings August 20, 2026 17:30
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f9bba444-2aef-4ca2-90fd-16823cf4289d


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

Adds rustynes-cosim, a C ABI and CLI for exporting deterministic NES emulation state as a verification oracle for a SystemVerilog DUT, and excludes it from the Cargo workspace to avoid forcing diagnostic trace features into the core builds.

Blocking issues

  • Missing C ABI capability for correct frame advancement: The PR correctly notes that the first run_frame() after power-on is swallowed by the reset latch, and adds Oracle::advance_frames(n) to safely handle this for the Rust CLI. However, the C API only exposes rn_run_frame and lacks any way to query the frame counter (rn_frame), query the CPU jam state (rn_is_jammed), or advance multiple frames robustly (rn_advance_frames). A Verilator C++ testbench using the C API will be forced to write a bare rn_run_frame loop and will inevitably hit the exact off-by-one frame desync and infinite-loop bugs documented in the PR.

Suggestions

  • crates/rustynes-cosim/src/lib.rs (around line 828 in rn_open): Oracle::new returns a Result with an error message on failure, but rn_open maps this to a flat NULL pointer, destroying the diagnostic context. Consider adding a way for the C API to retrieve the last error message (or returning an error code via an out-pointer) to align with the no-swallowed-errors principle correctly applied in write_error_code.
  • crates/rustynes-cosim/src/bin/nes_golden_export.rs: File I/O operations and ROM parsing use .unwrap_or_else(|e| panic!(...)). Per the project style guide (typed results over panics), consider returning a Result from main instead of panicking on user-provided file paths.

Nitpicks

  • crates/rustynes-cosim/src/bin/nes_golden_export.rs: frames is parsed as a u32 and then cast via u64::from() when calling advance_frames. You can parse it directly as u64.
  • In AGENTS.md and scripts/agy-review-selftest.sh, "behaviour" and "behavior" are used inconsistently when describing the Antigravity reviewer fix.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-20 19:03 UTC

Antigravity review (Gemini via Ultra)

Adds rustynes-cosim to expose the emulation core via a C ABI and a CLI tool, generating simulation goldens to verify an independent FPGA HDL implementation.

Blocking issues

  • Unvalidated external input reaching a sink: rn_write_cpu_boot_trace and rn_write_irq_trace_csv (crates/rustynes-cosim/src/lib.rs) pass the unvalidated path parameter from C directly to std::fs::write. A malicious or buggy testbench could traverse directories (e.g., ../../../etc/passwd) or overwrite arbitrary files.
  • Panics on untrusted input: nes_golden_export.rs uses unwrap_or_else(|e| panic!(...)) for file reads, file writes, and ROM parsing. Project rules mandate typed results over panics on external input; refactor main to return a Result and use ?.
  • Missing FFI boundary validation: rn_set_buttons casts port: c_uint directly to usize for Nes::set_buttons. If the core expects port < 4, an out-of-bounds C input will trigger a panic across the FFI boundary (Undefined Behavior). Add an explicit bounds check.
  • Build breakage on stable Rust:
    • The let-chain syntax (if self.irq_trace.is_some() && let Some(level) = ...) in crates/rustynes-core/src/bus.rs requires the unstable let_chains feature.
    • The .is_multiple_of() method on integers in crates/rustynes-core/src/cpu_boot_trace.rs requires the unstable is_multiple_of feature.
      Revert both to their stable equivalents (nested ifs and %).

Suggestions

  • crates/rustynes-cosim/src/bin/nes_golden_export.rs: If the hex crate is in the workspace, hex::encode(digest.as_slice()) is simpler than the manual byte-shifting loop in sha256_hex.

Nitpicks

  • The PR title claims v2.4.1, but Cargo.lock shows rustynes-cosim resolving to workspace version 2.3.9.
  • The CHANGELOG and PR description claim "The emulation core is untouched", but files in rustynes-core were modified to fix lints.
  • Unnecessary spelling churn (behaviour -> behavior) in the scripts/agy-review*.sh comments.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-20 18:28 UTC

Antigravity review (Gemini via Ultra)

This PR introduces the rustynes-cosim crate, providing a C ABI and CLI tool to use RustyNES as a verification oracle for an HDL NES core.

Blocking issues

  • Unvalidated external input reaching a sink: In crates/rustynes-cosim/src/lib.rs, rn_write_cpu_boot_trace and rn_write_irq_trace_csv accept unvalidated path strings from C and pass them directly to std::fs::write without checking for path traversal or authorized locations.
  • Clear error handling: crates/rustynes-cosim/src/bin/nes_golden_export.rs uses .expect() and .unwrap_or_else(|e| panic!(...)) for file I/O and parsing instead of returning typed Results (e.g., lines 123, 129, 134, 135). This panics on untrusted input.
  • Silent failure paths: In crates/rustynes-cosim/src/bin/nes_golden_export.rs line 117, the return value of write!(s, "{b:02x}") is explicitly swallowed with let _ =, violating the error handling rules.
  • Correctness/Build Failure: In crates/rustynes-core/src/cpu_boot_trace.rs line 415, body.len().is_multiple_of(RECORD_SIZE) relies on an unstable Nightly API for integers, breaking stable compilation. Revert to body.len() % RECORD_SIZE != 0.

Suggestions

  • crates/rustynes-core/src/bus.rs lines 3044-3045: Revert the if-let chain refactor. The original nested if statements already achieved the exact short-circuiting described in the new comment. Modifying it violates the "smallest correct change" rule and relies on newer/unstable syntax.

Nitpicks

  • crates/rustynes-cosim/src/bin/nes_golden_export.rs: The manual argument parsing loop is fragile; consider returning a Result instead of immediately exiting the process with usage() during parse failures.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-20 18:23 UTC

Antigravity 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

  • Feature Unification / Core Modification: rustynes-cosim is added to the workspace members and unconditionally enables cpu-boot-trace and irq-timing-trace on rustynes-core. Cargo's workspace feature unification means these heavy tracing paths will be enabled for all crates during a default or --workspace build, severely degrading emulator performance and violating the "emulation core is untouched" / "absent from the default build" claims.
  • Correctness (Filename Corruption): In crates/rustynes-cosim/src/bin/nes_golden_export.rs, base.with_extension("...") is used on a path derived from file_stem(). If the ROM name contains a dot (e.g., Super Mario Bros. 3.nes), with_extension truncates the name at the last dot, resulting in corrupted output filenames. Append to the filename string instead of using path extension replacement.
  • Ignored Return Value: In crates/rustynes-cosim/src/bin/nes_golden_export.rs, sha256_hex ignores the result of write!(s, "{b:02x}") using a let _ = binding, explicitly violating the project rule against ignored return values.
  • Swallowed Errors: In crates/rustynes-cosim/src/lib.rs, rn_write_cpu_boot_trace and rn_write_irq_trace_csv silently swallow std::io::Error via Err(_) => -3, hiding the actual cause of file write failures (e.g., disk full, permission denied) from the C ABI caller.

Suggestions

  • crates/rustynes-core/src/bus.rs: Verify that if self.irq_trace.is_some() && let Some(level) = ... (let_chains) is actually stabilized in your MSRV. If it's still unstable, this will break the build.
  • crates/rustynes-cosim/src/bin/nes_golden_export.rs: You can avoid the fmt::Write error-handling loop in sha256_hex entirely by formatting the digest directly: format!("{:x}", Sha256::digest(data)) (the output type implements LowerHex).

Nitpicks

None found.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

doublegate and others added 3 commits August 20, 2026 14:14
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
@doublegate

Copy link
Copy Markdown
Owner Author

Feature unification — you were right, and the fix is exclusion

Verified before acting, and the measurement is worth stating because it partly
contradicts the finding's framing
.

cargo build --workspace compiled rustynes-core once with the union
(--message-format=json, not inferred):

before: ['cpu-boot-trace', 'debug-hooks', 'default', 'hd-pack', 'irq-timing-trace', 'std']
after:  ['debug-hooks', 'default', 'hd-pack', 'std']

The performance claim was overstated. The instrumented build costs
+1.24% / +1.39% / +1.89% across the three full_frame benches — below this
project's own 3% adoption bar, and it never touched the shipped binary
(-p rustynes-frontend doesn't select this crate) or the perf gate
(cargo bench -p rustynes-core).

The real problem was worse than performance. irq-timing-trace selects a
different for sub_dot in 0..3 loop in Bus::tick_one_cpu_cycle — there are
two, under opposite cfgs. So CI's cargo test --workspace --release --features test-roms, the accuracy battery, was validating a scheduler no user runs.
That's this project's v2.3.4 defect repeating, and it's wrong at any percentage.

The crate is now in [workspace] exclude. Both costs of that are mechanically
closed rather than trusted:

  • cosim_manifest_audit.rs asserts every duplicated field and lint still equals
    the workspace's, and that the crate is still excluded — so re-adding it to
    members fails a test. Four mutations, all caught.
  • CI gains explicit fmt, clippy and test steps, since --all/--workspace
    no longer reach it.

The clippy step reported a must_use_candidate on its first run that
--workspace had never surfaced — so it isn't ceremony.

The other three findings

Filename corruption — real, fixed. with_extension truncated at the last
dot: Super Mario Bros. 3Super Mario Bros.ram.bin. Verified with a two-line
program. Now appends; the test asserts the correct name and that it differs from
with_extension's, so a "simplify this" is caught.

Discarded fmt::Result — fixed, built by hand with nothing fallible.
Your suggested format!("{:x}", …) does not compile: sha2 0.11 returns
hybrid_array::Array<u8, _>, which has no LowerHex — checked against a scratch
crate. Digests are pinned against the known SHA-256 of "" and "abc".

Swallowed io::Error — fixed. Was a flat -3 for every write failure; now
-(100 + errno), with -1..-9 reserved for malformed calls.

One suggestion declined

let-chains "verify they're stable in your MSRV". They are, and this is the
eighth time it has been raised. Stable in edition 2024 on the pinned stable
1.96.0; the identical construct has been in libretro_info_audit.rs on main
since v2.3.5; and the fmt + clippy + rustdoc job has compiled it green on this
branch.


AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff on the
shipped path, plus the full gate matrix and the excluded crate's own three gates.

@doublegate
doublegate merged commit 54b978c into main Aug 20, 2026
29 checks passed
@doublegate
doublegate deleted the feat/v2.4.1-cosim branch August 20, 2026 19:37
doublegate added a commit that referenced this pull request Aug 20, 2026
…#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants