Skip to content

ci(test): gate the goldens on PRs; keep run-ahead opt-in on measurement - #277

Merged
doublegate merged 5 commits into
mainfrom
feat/tripwire-runahead-lockstep
Jul 31, 2026
Merged

ci(test): gate the goldens on PRs; keep run-ahead opt-in on measurement#277
doublegate merged 5 commits into
mainfrom
feat/tripwire-runahead-lockstep

Conversation

@doublegate

@doublegate doublegate commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Third and final PR of the v1.26.0 hardening rung. Based on main (both prior PRs merged).

Two of this rung's three planned items turned out differently than planned once measured. Both are reported as found rather than forced to match the plan.

1. The golden vectors ran in no pull-request job

Only lint, test-light, and accuracysnes run on a PR. test-light never passes --features test-roms, and the accuracysnes job scoped itself to --test accuracysnes. So the 53 rendered-scene goldens and every framebuffer golden were reached only by full-test, which is mode == 'full'main, tags, the weekly cron.

A PR could shift PPU behaviour, move a golden, and hear nothing about it until merge-to-main. That's the same structure behind this project's own coprocessor-golden staleness (goldens left stale by post-bless PPU accuracy fixes): a golden vector nothing executes only accumulates drift. Here it wasn't that nothing ran them ever — it's that nothing ran them while it was still cheap to fix.

The accuracysnes job now also runs the scene, undisbeliever, rainwarrior, coprocessor, and save-state suites. Cost is bounded and measured: ~5 min for the three with a committed corpus (scenes ~84s, undisbeliever ~158s, rainwarrior ~54s). The *_oncart suites need gitignored dumps and self-skip for free — listed anyway so the intent is explicit rather than inferred from an omission, and so they start gating the moment a runner ever has that corpus.

On the planned "tripwire": it was scoped as a port of the sibling's expansion_level_tripwire. That mechanism does not port, and saying so is worth more than inventing a stand-in — the only commercial-roms-gated suite here (commercial_screenshots) is a screenshot generator that asserts nothing, so it holds no golden that can go stale, and PPU accuracy isn't parameterised by a small constant set the way expansion-audio levels are. Looking for the analogue is what surfaced the larger real gap above.

2. Run-ahead stays opt-in — measured, not assumed

docs/frontend.md recorded the per-frame save-state allocation as the blocker on run-ahead going default-on. v1.25.0 removed that allocation, so the question was re-measured rather than assumed resolved:

cost
save_state ~119 µs
load_state ~285 µs
save/load round trip ~0.40 ms
one emulated frame (headless_frame_steady_state) 6.39 ms
NTSC frame budget 16.64 ms

The round trip is 2.4% of the budget. It was never the dominant cost, and removing the allocation — a real improvement — did not move the decision. What run-ahead costs is the extra frame of emulation, inherent to the technique:

  • frames = 1 → 13.18 ms, 79% of budget, ~3.5 ms left for present/UI/audio on a fast dev machine
  • frames = 2 → 19.57 ms, 118% — cannot hold 60 fps at all

The plan for this rung said "default-on only if the measurement supports it; report the number either way." It doesn't, and this is the number.

One thing did change as a result. RunAheadConfig's Default is now hand-written so throttle_ms defaults to 14 ms (armed) rather than the derived 0.0 (disabled). The throttle is what its own doc calls the thing keeping run-ahead from turning a latency win into stutter — and the derived default left it off for precisely the user who'd just enabled run-ahead from Settings and had no throttle_ms line in their config. An existing config that spells the field out keeps its value (#[serde(default)] fills only absent fields), pinned by a negative-control test alongside the positive one.

3. Lockstep

Log row for RustyNES v2.2.1v2.2.4, and the two 2026-07-12 flags corrected: the GIF/WAV capture subsystem and CRT shader-preset depth were both closed by v1.25.0 (av_record.rs; the multi-pass stack + richer CRT + NTSC pass + .slangp import with the naga bridge), yet the roadmap still asked for a maintainer go/no-go on them two weeks later.

Also carries the verification comment for #276's declined blocking finding — documentation of that adjudication, not a fix.

Verification

  • cargo fmt --all --check, frontend clippy -D warnings — green.
  • New config tests pass (13/13 in config::tests), including the negative control.
  • The golden suites run green under the exact multi---test invocation the new CI step uses.
  • Both workflow files parse.

🤖 Generated with Claude Code

Summary

  • Expands AccuracySNES CI coverage with scene, undisbeliever, rainwarrior, coprocessor, save-state, and backward-compatibility golden suites.
  • Adds dossier assertions for these suites. The coverage denominator increases because these suites now contribute to the AccuracySNES evidence set.
  • Changes RunAheadConfig::default() to use throttle_ms: 14.0 with frames: 0. Explicit throttle_ms values remain unchanged.
  • The observable emulator behavior changes only for callers that use RunAheadConfig::default(): the throttle is armed at 14 ms, but run-ahead remains disabled until frames are configured.
  • Documents measured run-ahead costs. The claim is false if the documented measurements do not hold, or if an additional emulated frame fits within the NTSC frame budget.
  • Corrects roadmap status for GIF/WAV capture and CRT shader-preset depth.
  • Records the declined finding from #276 and documents GitHub API behavior used by release automation.
  • Validates formatting, frontend clippy with warnings denied, configuration tests, golden suites, documentation, and workflow parsing.

Third and final PR of the `v1.26.0` hardening rung.

## The golden vectors ran in no pull-request job

Only `lint`, `test-light`, and `accuracysnes` run on a PR. `test-light`
never passes `--features test-roms`, and the `accuracysnes` job scoped
itself to `--test accuracysnes`. So the 53 rendered-scene goldens and
every framebuffer golden were reached only by `full-test`, which is
`mode == 'full'` -- `main`, tags, the weekly cron. A PR could shift PPU
behaviour, move a golden, and hear nothing until merge-to-main.

That is the same structure behind this project's own coprocessor-golden
staleness (goldens left stale by post-bless PPU accuracy fixes): a golden
vector nothing executes only accumulates drift. Here it was not that
nothing ran them ever, but that nothing ran them while it was still cheap
to fix.

The job now also runs the scene, undisbeliever, rainwarrior, coprocessor,
and save-state suites. Cost is bounded and known: ~5 minutes for the
three with a committed corpus (scenes ~84s, undisbeliever ~158s,
rainwarrior ~54s). The `*_oncart` suites need gitignored commercial dumps
and firmware, so they self-skip for free; they are listed anyway so the
intent is explicit rather than inferred from an omission, and so they
start gating the moment a runner ever has that corpus.

Found while looking for RustySNES's analogue of the sibling's
`expansion_level_tripwire`. That mechanism does NOT port, and saying so
matters more than inventing a stand-in: the only `commercial-roms`-gated
suite here (`commercial_screenshots`) is a screenshot generator that
asserts nothing, so it holds no golden that can go stale, and PPU
accuracy is not parameterised by a small constant set the way
expansion-audio levels are. Looking for it surfaced a larger real gap.

## Run-ahead stays opt-in -- measured, not assumed

`docs/frontend.md` recorded the per-frame save-state allocation as THE
blocker on run-ahead going default-on. `v1.25.0` removed that allocation,
so the question was re-measured rather than assumed resolved:

  save_state                      ~119 us
  load_state                      ~285 us
  save/load round trip            ~0.40 ms
  one emulated frame              6.39 ms   (headless_frame_steady_state)
  NTSC frame budget              16.64 ms

The round trip is 2.4% of the budget. It was never the dominant cost, and
removing the allocation -- a real improvement -- did not move the
decision. What run-ahead costs is the extra frame of emulation, inherent
to the technique: `frames = 1` needs 79% of the budget, leaving ~3.5 ms
for present/UI/audio on a fast development machine; `frames = 2` needs
118% and cannot hold 60 fps at all.

So it stays opt-in. The plan for this rung said "default-on only if the
measurement supports it, and report the number either way" -- it does
not, and this is the number.

One thing did change as a result. `RunAheadConfig`'s `Default` is now
hand-written so `throttle_ms` defaults to 14 ms (ARMED) rather than the
derived `0.0` (DISABLED). The throttle is what its own doc calls the
thing that keeps run-ahead from turning a latency win into stutter, and
the derived default left it off for precisely the user who had just
enabled run-ahead from Settings and had no `throttle_ms` line in their
config. 14 ms sits below the 16.64 ms NTSC deadline with headroom and is
conservative against PAL's 20 ms. An existing config that spells the
field out keeps its value -- `#[serde(default)]` fills only absent
fields, pinned by a negative-control test alongside the positive one.

## Lockstep

Log row for RustyNES `v2.2.1`-`v2.2.4`, and the two 2026-07-12 flags
CORRECTED: the GIF/WAV capture subsystem and CRT shader-preset depth were
both closed by `v1.25.0` (`av_record.rs`; the multi-pass stack + richer
CRT + NTSC pass + `.slangp` import with the naga bridge), yet the roadmap
still asked for a maintainer go/no-go on them two weeks later.

Also carries the verification comment for #276's declined blocking
finding (that `git/matching-refs` 404s on an absent tag -- it returns
HTTP 200 with an empty array; `git/ref/tags` is the one that 404s, which
is why it was rejected). Documentation of that adjudication, not a fix.

Verified: `cargo fmt --check`, frontend clippy `-D warnings`, the new
config tests, and the golden suites run under the exact multi-`--test`
invocation the CI step uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 56d57b89-8017-425d-9b18-499d2fc1c7cf

📥 Commits

Reviewing files that changed from the base of the PR and between 7537536 and 41c63e6.

📒 Files selected for processing (1)
  • crates/rustysnes-frontend/src/config.rs
📜 Recent review details
⏰ Context from checks skipped due to timeout. (5)
  • GitHub Check: test-light
  • GitHub Check: lint
  • GitHub Check: accuracysnes
  • GitHub Check: build demo + docs
  • GitHub Check: review
🧰 Additional context used
📓 Path-based instructions (8)
crates/**/*.rs

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

crates/**/*.rs: Preserve the master-clock lockstep timing model.
rustysnes-core::Bus owns mutable machine state, and the CPU borrows &mut Bus.
Preserve determinism: seed, ROM, and input must produce bit-identical output.
Treat test ROMs as the behavioral specification; when documentation disagrees with passing ROM behavior, update the documentation.
Keep unsafe confined to existing allowed areas, namely frontend and FFI code, and document every unsafe block with a // SAFETY: comment.

Files:

  • crates/rustysnes-frontend/src/config.rs
**/*.rs

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*.rs: Use Rust edition 2024 and the toolchain pinned in rust-toolchain.toml (Rust 1.96).
Run cargo fmt --all --check; Rust code must remain rustfmt-compliant.
Run Clippy with cargo clippy --workspace --all-targets -- -D warnings; warnings must not remain.
New public Rust items must have rustdoc because missing_docs is a workspace lint.
Do not run cargo clippy --all-features; scripting and script-wasm are mutually exclusive. Use explicit per-feature jobs instead.

**/*.rs: Do not introduce .unwrap(), .expect(), or panic!() on untrusted external input—such as ROM/save-state bytes, netplay messages, Lua or scripting input, or user-supplied paths—outside #[cfg(test)] code. Use typed errors at those boundaries; locally constructed values or values immediately protected by a checked invariant are allowed.
Every new unsafe { ... } block or unsafe fn must have an adjacent // SAFETY: comment naming the relied-on invariant and its guarantor. Unsafe code outside the frontend and FFI shims should additionally be questioned because unsafe_code is a workspace lint.

**/*.rs: Use Rust edition 2024 with the pinned 1.96 toolchain; satisfy workspace pedantic, nursery, missing_docs, and unsafe_code warnings because CI runs with -D warnings. Document every public item.
Keep unsafe code restricted to the frontend and FFI, and include a // SAFETY: justification for each use.
Keep hot paths allocation-free.
Treat rustysnes_core::Bus as the owner of mutable emulator state; the CPU borrows &mut Bus.
Use the master clock at 21477270 Hz as the timing master; advance the scheduler in lockstep and run other chips on their divisors.
Maintain determinism: seed, ROM, and input must produce bit-identical audio/video; frontend rate control must not alter emulation results.
When implementing hardware behavior, pin and run the failing test ROM first; treat test ROMs as the specification.

Files:

  • crates/rustysnes-frontend/src/config.rs
**/*.{rs,md}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Chip-behavior changes must update both the chip implementation and the corresponding docs/<subsystem>.md documentation.

A chip change must update both the chip implementation and its corresponding docs/<chip>.md documentation in the same change.

Files:

  • crates/rustysnes-frontend/src/config.rs
**/*

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*: Do not commit or vendor the generated snesdev_wiki/ mirror; it is gitignored and intended only as a local reference.
Keep commits focused and use Conventional Commits: <type>(<scope>): <subject>, with an imperative subject of at most 72 characters.
Do not use emojis in code, comments, or commit messages.
Before opening a PR, ensure formatting, Clippy, workspace tests, the core embedded build, rustdoc with warnings denied, documentation coverage, and changelog requirements pass.
Ticket completion must be reflected in the relevant to-dos/ sprint file.

**/*: Preserve the one-directional crate graph: chip crates must not depend on one another; rustysnes-core ties them together.
Never commit commercial ROMs; only commit derived screenshots and hashes.
Keep docs/STATUS.md as the authoritative per-subsystem status and update project documentation in the same PR as code changes.
Do not treat RustyNES v2.0 or engine-lineage anchors as project releases.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/rustysnes-*/**/*

📄 CodeRabbit inference engine (Custom checks)

For the full pull request diff against its base branch, any observable behavior change under crates/rustysnes-<chip>/ must be accompanied by an edit to the matching docs/<chip>.md; a crate change passes without documentation only when it does not alter observable behavior, with the non-behavioral change stated explicitly.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/rustysnes-frontend/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

Frontend code is one of the permitted locations for unsafe; any unsafe use still requires a // SAFETY: comment.

Files:

  • crates/rustysnes-frontend/src/config.rs
**/*.{rs,toml}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{rs,toml}: Additive features must be default-off so shipped/native, no_std, and wasm builds remain byte-identical.
Never use or configure --all-features; validate opt-in feature combinations individually as required by the project recipe.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/**

⚙️ CodeRabbit configuration file

crates/**: Emulator core. Hot paths are allocation-free; unsafe requires a // SAFETY: comment
naming the invariant. Any change to save-stated fields needs a FORMAT_VERSION bump and a
docs/adr/0006 bump-log entry. Behavior changes must update the matching docs/<chip>.md
in the same change.

Files:

  • crates/rustysnes-frontend/src/config.rs
🔇 Additional comments (3)
crates/rustysnes-frontend/src/config.rs (3)

935-940: Pin the documented 14.0 ms default in both tests.

The first assertion compares against DEFAULT_RUN_AHEAD_THROTTLE_MS, and the deserialization test checks only > 0.0. A future change to another positive value would pass both tests while changing the user-facing default. Assert 14.0 for RunAheadConfig::default() and for an omitted throttle_ms field.

Also applies to: 980-983


583-602: LGTM!


613-643: LGTM!


Walkthrough

The PR expands AccuracySNES CI coverage, documents release tag lookup behavior, changes the default run-ahead throttle to 14 ms while keeping run-ahead disabled, adds validation tests and timing documentation, and updates project status records.

Changes

CI and release automation

Layer / File(s) Summary
Golden-vector CI coverage
.github/workflows/ci.yml, CHANGELOG.md
The accuracysnes job runs ten golden-vector suites with --no-fail-fast. The changelog records the added coverage.
Release tag lookup documentation
.github/workflows/release-auto.yml
Comments document GitHub API responses for absent tags and require lookup failures to abort.

Run-ahead configuration

Layer / File(s) Summary
Run-ahead default and validation
crates/rustysnes-frontend/src/config.rs
RunAheadConfig::default() sets frames to 0 and throttle_ms to 14.0. Tests cover default values, frame budget, omitted values, and explicit 0.0.
Run-ahead documentation
CHANGELOG.md, docs/frontend.md
Documentation records measured save/load and emulation timings, opt-in behavior, frame-budget constraints, and the new default throttle.

Project status records

Layer / File(s) Summary
Lockstep and roadmap updates
to-dos/LOCKSTEP-CHECKLIST.md, to-dos/ROADMAP.md
The records document the 2026-07-31 lockstep assessment and closure of the GIF/WAV capture and CRT shader roadmap items.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 10
✅ Passed checks (10 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title uses a valid Conventional Commit format and accurately summarizes the CI golden-test and run-ahead changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Changelog Entry ✅ Passed The full PR diff against origin/main includes a CHANGELOG.md hunk documenting the RunAheadConfig default throttle change and expanded golden-vector CI coverage.
Docs-As-Spec ✅ Passed The only crate change is rustysnes-frontend; RunAheadConfig::default() arms throttle_ms at 14 ms, and the same PR updates docs/frontend.md to specify this behavior.
Accuracysnes Bookkeeping ✅ Passed Against origin/main, the PR changes seven non-AccuracySNES files and has no additions, removals, or paths under tests/roms/AccuracySNES/gen/src/; bookkeeping checks are not triggered.
No Panic On Untrusted Input ✅ Passed The PR adds only two .expect("deserialize") calls, both inside #[cfg(test)] mod tests; the production diff adds no new unwrap, expect, or panic! on external input.
Safety Comment On New Unsafe ✅ Passed The PR diff adds no unsafe { ... } blocks or unsafe fn declarations, so no new unsafe construct lacks an adjacent // SAFETY: comment.

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

…budget

Two review suggestions applied, both verified rather than taken on
description -- and the first one corrected my own wrong assumption.

`(x - 0.0).abs() < f32::EPSILON` -> `x == 0.0`. I expected this to trip
`clippy::float_cmp` (the workspace runs `pedantic` at `-D warnings`) and
was going to decline it on that basis; testing showed clippy exempts a
constant comparison, so the suggestion is right and the epsilon form was
just noise. The FORM matters though, and the comment now says so: the
apparently-more-idiomatic `assert_eq!(x, 0.0)` DOES fire `float_cmp` and
fails the gate, so `assert!(x == 0.0)` is the spelling that works.

The magic `1000.0 / 60.0988` now derives from `Region::Ntsc.frame_rate()`
so the two cannot drift apart, compared in `f64` (what `frame_rate()`
returns) to avoid a lossy `as f32` the pedantic cast lints would object
to.

Declined: the PR title is 71 characters, not the 73 the review reported,
so it is already inside the 72-character limit.

Verified: `cargo fmt --check`, frontend clippy `-D warnings`, and
`config::tests` 13/13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@doublegate

Copy link
Copy Markdown
Owner Author

Review adjudication

No blocking issues. Three findings — two applied, one declined with a measurement. One of the applied ones corrected an assumption of mine.

Applied: (x - 0.0).abs() < f32::EPSILONx == 0.0

Right, and I was about to decline it for the wrong reason. The workspace runs pedantic at -D warnings, so I expected clippy::float_cmp to fire and make the epsilon form necessary. Testing showed clippy exempts a constant comparison — the epsilon dance was just noise.

The form does matter, though, and the comment now records it: the apparently-more-idiomatic assert_eq!(x, 0.0) does fire float_cmp and fails the gate. assert!(x == 0.0) is the spelling that works. Verified both ways rather than reasoned about.

Applied: derive the NTSC budget instead of a magic number

1000.0 / 60.09881000.0 / Region::Ntsc.frame_rate(), so the assertion and region_frame_rates_and_heights cannot drift apart. Compared in f64 (what frame_rate() returns) rather than casting to f32, which avoids a lossy as the pedantic cast lints would object to.

Declined: "PR title is 73 characters, over the 72 limit"

Miscounted — it is 71:

$ t="ci(test): run the goldens on PRs; measure run-ahead instead of assuming"
$ echo ${#t}
71

Already inside the limit, so no change.

Verification

  • cargo fmt --all --check, frontend clippy -D warnings, config::tests 13/13.
  • The accuracysnes CI job's new "Run the golden vectors" step ran and passed on this PR — 04:46:02 → 04:51:57, ~6 min, against the ~5 min estimate in the PR body. So the change is confirmed working in CI, not just locally.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/rustysnes-frontend/src/config.rs`:
- Around line 922-967: Update the tests
run_ahead_is_off_by_default_but_its_throttle_is_armed and
an_explicit_throttle_of_zero_survives_the_new_default to assert the exact 14.0
ms throttle_ms value for RunAheadConfig::default() and for deserialization when
throttle_ms is omitted, while retaining the explicit 0.0 opt-out assertion and
existing frames checks.

In `@to-dos/LOCKSTEP-CHECKLIST.md`:
- Line 56: Correct the historical CI hardening counts in the checklist entry:
state that all 17 checkouts lacked persist-credentials: false before v1.26.0 and
that all 32 action references used mutable tags. Leave the release-auto.yml:96
finding and the v1.26.0 disposition unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bfc3b254-ba7b-4f80-9765-e1774907fbb2

📥 Commits

Reviewing files that changed from the base of the PR and between 81baef5 and c0f4a58.

📒 Files selected for processing (7)
  • .github/workflows/ci.yml
  • .github/workflows/release-auto.yml
  • CHANGELOG.md
  • crates/rustysnes-frontend/src/config.rs
  • docs/frontend.md
  • to-dos/LOCKSTEP-CHECKLIST.md
  • to-dos/ROADMAP.md
📜 Review details
⏰ Context from checks skipped due to timeout. (4)
  • GitHub Check: test-light
  • GitHub Check: lint
  • GitHub Check: accuracysnes
  • GitHub Check: build demo + docs
🧰 Additional context used
📓 Path-based instructions (13)
**/*.{rs,md}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Chip-behavior changes must update both the chip implementation and the corresponding docs/<subsystem>.md documentation.

A chip change must update both the chip implementation and its corresponding docs/<chip>.md documentation in the same change.

Files:

  • to-dos/ROADMAP.md
  • docs/frontend.md
  • to-dos/LOCKSTEP-CHECKLIST.md
  • crates/rustysnes-frontend/src/config.rs
  • CHANGELOG.md
**/*

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*: Do not commit or vendor the generated snesdev_wiki/ mirror; it is gitignored and intended only as a local reference.
Keep commits focused and use Conventional Commits: <type>(<scope>): <subject>, with an imperative subject of at most 72 characters.
Do not use emojis in code, comments, or commit messages.
Before opening a PR, ensure formatting, Clippy, workspace tests, the core embedded build, rustdoc with warnings denied, documentation coverage, and changelog requirements pass.
Ticket completion must be reflected in the relevant to-dos/ sprint file.

**/*: Preserve the one-directional crate graph: chip crates must not depend on one another; rustysnes-core ties them together.
Never commit commercial ROMs; only commit derived screenshots and hashes.
Keep docs/STATUS.md as the authoritative per-subsystem status and update project documentation in the same PR as code changes.
Do not treat RustyNES v2.0 or engine-lineage anchors as project releases.

Files:

  • to-dos/ROADMAP.md
  • docs/frontend.md
  • to-dos/LOCKSTEP-CHECKLIST.md
  • crates/rustysnes-frontend/src/config.rs
  • CHANGELOG.md
**/*.md

⚙️ CodeRabbit configuration file

**/*.md: Docs are the spec, not a changelog. Flag prose that has drifted from the code it describes
rather than style nits. The markdownlint gate is pinned to v0.39.0 via pre-commit —
do not report rules that version does not have (MD060 in particular).

Files:

  • to-dos/ROADMAP.md
  • docs/frontend.md
  • to-dos/LOCKSTEP-CHECKLIST.md
  • CHANGELOG.md
.github/workflows/**

⚙️ CodeRabbit configuration file

.github/workflows/**: Flag third-party actions pinned to a branch rather than a tag or commit SHA, jobs without
a least-privilege permissions: block, and secrets that could reach the log. The
ci-success job is the required check and must depend on every gate it claims to
aggregate — a new job that is not in its needs: list is a gate nothing enforces.

Files:

  • .github/workflows/release-auto.yml
  • .github/workflows/ci.yml
docs/**/*.md

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Before changing a subsystem, consult docs/architecture.md, docs/STATUS.md, CONTRIBUTING.md, the relevant subsystem documentation, and applicable ADRs.

New subsystems must add documentation under docs/.

Files:

  • docs/frontend.md
docs/**

⚙️ CodeRabbit configuration file

docs/**: Docs are the spec, not a history log. Flag claims that contradict the code, counts that
contradict the generated docs/accuracysnes-coverage.md, and any statement of coverage that
is broader than what the corresponding test actually asserts.

Files:

  • docs/frontend.md
crates/**/*.rs

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

crates/**/*.rs: Preserve the master-clock lockstep timing model.
rustysnes-core::Bus owns mutable machine state, and the CPU borrows &mut Bus.
Preserve determinism: seed, ROM, and input must produce bit-identical output.
Treat test ROMs as the behavioral specification; when documentation disagrees with passing ROM behavior, update the documentation.
Keep unsafe confined to existing allowed areas, namely frontend and FFI code, and document every unsafe block with a // SAFETY: comment.

Files:

  • crates/rustysnes-frontend/src/config.rs
**/*.rs

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*.rs: Use Rust edition 2024 and the toolchain pinned in rust-toolchain.toml (Rust 1.96).
Run cargo fmt --all --check; Rust code must remain rustfmt-compliant.
Run Clippy with cargo clippy --workspace --all-targets -- -D warnings; warnings must not remain.
New public Rust items must have rustdoc because missing_docs is a workspace lint.
Do not run cargo clippy --all-features; scripting and script-wasm are mutually exclusive. Use explicit per-feature jobs instead.

**/*.rs: Do not introduce .unwrap(), .expect(), or panic!() on untrusted external input—such as ROM/save-state bytes, netplay messages, Lua or scripting input, or user-supplied paths—outside #[cfg(test)] code. Use typed errors at those boundaries; locally constructed values or values immediately protected by a checked invariant are allowed.
Every new unsafe { ... } block or unsafe fn must have an adjacent // SAFETY: comment naming the relied-on invariant and its guarantor. Unsafe code outside the frontend and FFI shims should additionally be questioned because unsafe_code is a workspace lint.

**/*.rs: Use Rust edition 2024 with the pinned 1.96 toolchain; satisfy workspace pedantic, nursery, missing_docs, and unsafe_code warnings because CI runs with -D warnings. Document every public item.
Keep unsafe code restricted to the frontend and FFI, and include a // SAFETY: justification for each use.
Keep hot paths allocation-free.
Treat rustysnes_core::Bus as the owner of mutable emulator state; the CPU borrows &mut Bus.
Use the master clock at 21477270 Hz as the timing master; advance the scheduler in lockstep and run other chips on their divisors.
Maintain determinism: seed, ROM, and input must produce bit-identical audio/video; frontend rate control must not alter emulation results.
When implementing hardware behavior, pin and run the failing test ROM first; treat test ROMs as the specification.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/rustysnes-*/**/*

📄 CodeRabbit inference engine (Custom checks)

For the full pull request diff against its base branch, any observable behavior change under crates/rustysnes-<chip>/ must be accompanied by an edit to the matching docs/<chip>.md; a crate change passes without documentation only when it does not alter observable behavior, with the non-behavioral change stated explicitly.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/rustysnes-frontend/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

Frontend code is one of the permitted locations for unsafe; any unsafe use still requires a // SAFETY: comment.

Files:

  • crates/rustysnes-frontend/src/config.rs
**/*.{rs,toml}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{rs,toml}: Additive features must be default-off so shipped/native, no_std, and wasm builds remain byte-identical.
Never use or configure --all-features; validate opt-in feature combinations individually as required by the project recipe.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/**

⚙️ CodeRabbit configuration file

crates/**: Emulator core. Hot paths are allocation-free; unsafe requires a // SAFETY: comment
naming the invariant. Any change to save-stated fields needs a FORMAT_VERSION bump and a
docs/adr/0006 bump-log entry. Behavior changes must update the matching docs/<chip>.md
in the same change.

Files:

  • crates/rustysnes-frontend/src/config.rs
CHANGELOG.md

📄 CodeRabbit inference engine (CONTRIBUTING.md)

User-visible changes must be recorded under the [Unreleased] section.

For the full pull request diff against its base branch, modify CHANGELOG.md when user-visible behavior changes, including emulator output, frontend features, CLI flags, public APIs, or AccuracySNES cartridge contents. Do not require it for purely internal changes, tests, comments, or CI configuration.

Files:

  • CHANGELOG.md
🪛 LanguageTool
to-dos/LOCKSTEP-CHECKLIST.md

[style] ~56-~56: This sentence may be long and difficult for your reader to follow. Consider inserting a period and starting a new sentence here.
Context: ...DIATELY** in v1.26.0 — all three items, with one divergence found by auditing per-site rather than copyin...

(WITH_THE_SENTENCE)


[grammar] ~56-~56: Ensure spelling is correct
Context: ...compile gate that does not exist there, where RustySNES's has both a per-PR compile gate and a sc...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (8)
.github/workflows/release-auto.yml (1)

126-138: LGTM!

to-dos/ROADMAP.md (1)

387-397: LGTM!

crates/rustysnes-frontend/src/config.rs (2)

583-602: LGTM!


613-637: LGTM!

CHANGELOG.md (2)

12-32: LGTM!


33-59: LGTM!

docs/frontend.md (1)

587-625: LGTM!

.github/workflows/ci.yml (1)

341-371: LGTM!

Comment thread crates/rustysnes-frontend/src/config.rs
| 2026-07-11 | `v2.1.5` (released) / `main` (v2.1.6 "Expansion Audio" in progress) | Initial baseline for the RustyNES-parity roadmap — see the full gap analysis in the roadmap plan. | Roadmap `v1.5.0`–`v1.19.0` scoped against this baseline. | First entry; establishes the checklist itself. |
| 2026-07-12 | `v2.1.10` / `main` | 5 RustyNES releases since baseline (`v2.1.6`–`v2.1.10`). New: a GIF/WAV screen+audio capture subsystem (`v2.1.9`); 3 marquee libretro-slang CRT presets (CRT-Royale, guest-advanced, Sony Megatron, `v2.1.9`) vs. RustySNES's own single scanline+aperture-mask `Crt` filter; a SIMD software blitter + wasm-size/startup pass (`v2.1.8`); browser RetroAchievements + Vs. DualSystem libretro support (`v2.1.10`). Raw NTSC composite-signal decode (`v2.1.9`) confirmed genuinely NES-specific (8:1 dot-clock subsampling, no SNES analog) — validates `v1.12.0`'s own out-of-scope call. `v2.1.6`/`v2.1.7` (expansion-audio oracle, 2A03/PPU die-revision modeling) are NES-chip-specific, not applicable. | Capture subsystem: **large catch-up**, no RustySNES rung exists — flagged in `to-dos/ROADMAP.md`'s "Milestones beyond the phases" for a maintainer go/no-go, not silently scoped in. CRT-preset depth: **medium catch-up**, noted as a future-rung candidate, not urgent (RustySNES's existing `Crt` filter is functional). SIMD blitter/wasm pass: **small catch-up**, could fold into `v1.19.0 "Afterburner"` alongside PGO/BOLT when that rung is scoped. Browser RA + Vs. DualSystem: correctly out of scope (RustySNES's RA is native-only; no Vs. System equivalent on SNES). Raw-composite: reconfirmed already-covered/correctly-deferred. | First re-run since baseline — overdue (8 RustySNES releases, `v1.5.0`–`v1.12.0`, had landed with no lockstep check in between); run at the start of scoping `v1.13.0`. |
| 2026-07-15 | `v2.2.0 "Capstone"` / `main` | RustyNES cut `v2.2.0` since the last check (`v2.1.10`), plus an `[Unreleased]` Dependabot-consolidation PR. New in `v2.2.0`: netplay lobby/matchmaking (browse-and-join room directory + server-side quick-match), delayed-stream spectators, a graded hysteresis-backed desync verdict, peer-liveness RTT timeouts (all `rustynes-netplay` signaling depth); fuzz-target expansion from 3 → 8 cargo-fuzz targets (PPU/APU reg I/O, netplay message parsing, save-state, movie); a read-only ROM Info debugger panel (CRC32/SHA-256/header decode); 4 new MkDocs handbook pages; FDS medium-model completion (CRC-16/gap/continuous head-seek) + Famicom mic + Zapper 3×3 aperture hardening (peripherals); a fuzz-found movie-deserializer OOM-DoS hardening (untrusted `frame_count`/zero-`bytes_per_frame`). Checked RustySNES's own state directly (not assumed): confirmed no `fuzz/` directory exists at all; confirmed `rustysnes-netplay` has no `SignalMessage`/`ListRooms`/`QuickMatch`/`delay_frames`/`DesyncStatus`/`PeerLink` equivalents; confirmed `rustysnes-frontend/src/debugger/` has no ROM-Info-panel equivalent; confirmed `rustysnes-core::movie`'s deserializer is ALREADY hardened against the identical untrusted-`frame_count`/zero-length-frame OOM class (a `deserialize_rejects_a_forged_huge_frame_count_without_oom` regression test already exists — independently discovered, not copied); confirmed RustySNES's `SuperScopeState` uses a purely geometric (coordinate-clamp) offscreen check, architecturally different from a photodiode-luma-sampling model, so the Zapper aperture technique doesn't map onto it. FDS/Famicom-mic reconfirmed NES/Famicom-specific, not applicable. | **No fuzzing infrastructure at all**: **large catch-up** — genuinely new quality-infra category (not incremental growth of anything RustySNES has), and this project's own `docs/testing-strategy.md` already names fuzzability as an intended Layer 1 property never actually built out; flagged in `to-dos/ROADMAP.md`'s "Milestones beyond the phases" for a maintainer go/no-go. **Netplay lobby/matchmaking + spectator/desync/liveness depth**: **large catch-up** — multiple sub-features, would displace no currently-open rung (the ladder is closed) but is genuinely multiple PRs' worth; the `v1.5.0`-era "netplay already at parity" call is now stale against this deepened baseline; also flagged in `ROADMAP.md`. **ROM Info debugger panel**: **small catch-up**, self-contained, fits cleanly into the existing `debugger/` module architecture — candidate for the next time a small patch/quality release is scoped. **MkDocs handbook pages**: already covered in spirit (RustySNES already maintains its own MkDocs site, updated per-release; no distinct action). **Movie-deserializer OOM-DoS hardening**: already covered — RustySNES independently already has equivalent protection. **Zapper aperture hardening**: not directly applicable — architecture mismatch (RustySNES's Super Scope model doesn't sample framebuffer luma at all). **FDS/Famicom mic**: correctly out of scope (no SNES equivalent hardware). **Dependency-consolidation PR**: routine Dependabot hygiene, not a roadmap-scope item. | First re-run since the roadmap ladder closed at `v1.19.0` — confirms the checklist itself stays a live, standing practice even with no open rung to fold findings into; both large-catch-up items intentionally left unscoped pending a maintainer decision rather than opening a new rung unilaterally. |
| 2026-07-31 | `v2.2.4 "Cartridge"` / `main` | RustyNES cut four releases since the last check (`v2.2.0`): `v2.2.1`, `v2.2.2 "Conduit"`, `v2.2.3 "Datum"`, `v2.2.4 "Cartridge"`. **CI supply-chain hardening** (`v2.2.2`): `persist-credentials: false` on all 19 checkouts, a fail-closed release-tag existence check replacing a three-way-collapsing `git ls-remote`, and a pinned toolchain action. Checked RustySNES's own state directly rather than assuming parity: 15 of its 17 checkouts were unhardened, `release-auto.yml:96` carried the identical `ls-remote` bug, and 34 action references were on mutable tags. **Libretro buildbot recipe** (`.gitlab-ci.yml`, `v2.2.2`) putting the core on RetroArch's in-app downloader; RustySNES has `rustysnes-libretro` but no buildbot recipe. **PPU fast-dot-path promoted to default + PGO** (`v2.2.3`) — NES-specific hot path, and RustySNES already shipped its own PGO/BOLT pipeline in `v1.19.0`. **`expansion_level_tripwire`** (`v2.2.3`) — a CI-run unit test pinning the audio level constants that seven `commercial-roms`-gated golden rows depend on, after those rows sat stale for 28 days. **Mapper residuals + 5B audio calibration** (`v2.2.3`) and **`.info` metadata + FDS disk control** (`v2.2.4`): NES/FDS-specific. **Antigravity reviewer template sync** (`v2.2.4`): RustySNES already did this in `v1.25.0` (#270/#273). | **CI hardening**: **small catch-up, ADOPTED IMMEDIATELY** in `v1.26.0` — all three items, with one divergence found by auditing per-site rather than copying the conclusion: `release-auto.yml`'s `prepare` job **genuinely needs** the checkout credential (it `git push`es the annotated tag), where the sibling's audit found no job did. A blanket sweep would have broken every future release. **Golden-staleness tripwire**: the *mechanism* does not port — RustySNES's only `commercial-roms`-gated suite (`commercial_screenshots`) is a screenshot generator that asserts nothing, so it holds no golden that can go stale, and PPU accuracy is not parameterised by a small constant set the way expansion-audio levels are. But checking for the analogue surfaced a **real and worse** structural gap, closed in `v1.26.0`: the 53 rendered-scene goldens and every framebuffer golden ran in **no pull-request job at all** (only `full-test`, which is main/tags/schedule), which is how this project's own coprocessor-golden staleness happened. **Libretro buildbot**: **medium catch-up**, no rung — candidate for a future distribution cut, flagged in `ROADMAP.md` rather than folded in. **Fast dot path / PGO / mapper / FDS / `.info`**: not applicable or already covered. | First re-run since the fuzzing item from the 2026-07-15 row was closed. Note the sibling is now *behind* on one axis: its `fuzz/` is wired into no workflow and its README claims a compile gate that does not exist there, where RustySNES's has both a per-PR compile gate and a scheduled campaign. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

workflow_dir=".github/workflows"

echo "Checkout and credential settings:"
rg -n -C 4 'actions/checkout@|persist-credentials:' "$workflow_dir"

echo "Release tag lookup:"
rg -n -C 8 'git ls-remote|refs/tags|release tag|tag exists' \
  "$workflow_dir/release-auto.yml"

echo "All action references:"
rg -n '^[[:space:]]*uses:[[:space:]]*[^#[:space:]]+@' "$workflow_dir"

Repository: doublegate/RustySNES

Length of output: 21200


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "Current workflow counts:"
python3 - <<'PY'
from pathlib import Path
import re

checkout_total = 0
checkout_hardened = 0
action_total = 0
mutable = []
for path in sorted(Path(".github/workflows").glob("*.yml")):
    lines = path.read_text().splitlines()
    for n, line in enumerate(lines, 1):
        m = re.search(r"uses:\s*([^#\s]+)@([^\s#]+)", line)
        if not m:
            continue
        action_total += 1
        owner_repo, ref = m.groups()
        if not re.fullmatch(r"[0-9a-fA-F]{40}", ref):
            mutable.append((path, n, owner_repo, ref))
        if owner_repo == "actions/checkout":
            checkout_total += 1
            window = "\n".join(lines[n:min(n + 8, len(lines))])
            if re.search(r"persist-credentials:\s*false", window):
                checkout_hardened += 1

print(f"checkout_total={checkout_total}")
print(f"checkout_persist_credentials_false={checkout_hardened}")
print(f"action_total={action_total}")
print(f"mutable_action_refs={len(mutable)}")
for item in mutable:
    print("%s:%d %s@%s" % item)
PY

echo "Relevant history:"
git log --all --oneline --decorate -n 20 -- to-dos/LOCKSTEP-CHECKLIST.md .github/workflows

Repository: doublegate/RustySNES

Length of output: 1984


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import re
import subprocess

commits = {
    "before_81baef5": "81baef5^",
    "after_81baef5": "81baef5",
}

def files_at(commit):
    out = subprocess.check_output(
        ["git", "ls-tree", "-r", "--name-only", commit, ".github/workflows"],
        text=True,
    )
    return [line for line in out.splitlines() if line.endswith((".yml", ".yaml"))]

def content_at(commit, path):
    return subprocess.check_output(["git", "show", f"{commit}:{path}"], text=True)

for label, commit in commits.items():
    checkout_total = checkout_hardened = action_total = 0
    mutable = []
    ls_remote = []
    for path in files_at(commit):
        lines = content_at(commit, path).splitlines()
        for n, line in enumerate(lines, 1):
            m = re.search(r"uses:\s*([^#\s]+)@([^\s#]+)", line)
            if not m:
                continue
            action_total += 1
            owner_repo, ref = m.groups()
            if not re.fullmatch(r"[0-9a-fA-F]{40}", ref):
                mutable.append((path, n, owner_repo, ref))
            if owner_repo == "actions/checkout":
                checkout_total += 1
                window = "\n".join(lines[n:min(n + 8, len(lines))])
                if re.search(r"persist-credentials:\s*false", window):
                    checkout_hardened += 1
        for n, line in enumerate(lines, 1):
            if "git ls-remote" in line:
                ls_remote.append((path, n, line.strip()))
    print(f"{label}:")
    print(f"  checkout_total={checkout_total}")
    print(f"  checkout_persist_credentials_false={checkout_hardened}")
    print(f"  checkout_unhardened={checkout_total - checkout_hardened}")
    print(f"  action_total={action_total}")
    print(f"  mutable_action_refs={len(mutable)}")
    for item in mutable:
        print("   ", item)
    print(f"  ls_remote={ls_remote}")

print("Hardening commit summary:")
print(subprocess.check_output(
    ["git", "show", "--stat", "--oneline", "--no-renames", "81baef5"],
    text=True,
))
PY

Repository: doublegate/RustySNES

Length of output: 3881


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import re
import subprocess

commits = {
    "before_81baef5": "81baef5^",
    "after_81baef5": "81baef5",
}

def files_at(commit):
    out = subprocess.check_output(
        ["git", "ls-tree", "-r", "--name-only", commit, ".github/workflows"],
        text=True,
    )
    return [p for p in out.splitlines() if p.endswith((".yml", ".yaml"))]

def text_at(commit, path):
    return subprocess.check_output(["git", "show", f"{commit}:{path}"], text=True)

for label, commit in commits.items():
    checkouts = hardened = actions = 0
    mutable = []
    ls_remote = []

    for path in files_at(commit):
        lines = text_at(commit, path).splitlines()
        for n, line in enumerate(lines, 1):
            match = re.search(r"uses:\s*([^#\s]+)@([^\s#]+)", line)
            if not match:
                continue
            actions += 1
            owner_repo, ref = match.groups()
            if not re.fullmatch(r"[0-9a-fA-F]{40}", ref):
                mutable.append((path, n, owner_repo, ref))
            if owner_repo == "actions/checkout":
                checkouts += 1
                block = "\n".join(lines[n:min(n + 8, len(lines))])
                if re.search(r"persist-credentials:\s*false", block):
                    hardened += 1

        for n, line in enumerate(lines, 1):
            if "git ls-remote" in line:
                ls_remote.append((path, n, line.strip()))

    print(label)
    print(f"  checkouts={checkouts}")
    print(f"  hardened={hardened}")
    print(f"  unhardened={checkouts - hardened}")
    print(f"  actions={actions}")
    print(f"  mutable={len(mutable)}")
    print(f"  ls_remote={ls_remote}")
    for item in mutable:
        print(f"  mutable_ref={item}")

print("Hardening commit:")
print(subprocess.check_output(
    ["git", "show", "--stat", "--oneline", "--no-renames", "81baef5"],
    text=True,
))
PY

Repository: doublegate/RustySNES

Length of output: 4077


Correct the historical audit counts. Before v1.26.0, all 17 checkouts lacked persist-credentials: false, and all 32 action references used mutable tags. The release-auto.yml:96 bug and the v1.26.0 disposition are correct.

🧰 Tools
🪛 LanguageTool

[style] ~56-~56: This sentence may be long and difficult for your reader to follow. Consider inserting a period and starting a new sentence here.
Context: ...DIATELY** in v1.26.0 — all three items, with one divergence found by auditing per-site rather than copyin...

(WITH_THE_SENTENCE)


[grammar] ~56-~56: Ensure spelling is correct
Context: ...compile gate that does not exist there, where RustySNES's has both a per-PR compile gate and a sc...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@to-dos/LOCKSTEP-CHECKLIST.md` at line 56, Correct the historical CI hardening
counts in the checklist entry: state that all 17 checkouts lacked
persist-credentials: false before v1.26.0 and that all 32 action references used
mutable tags. Leave the release-auto.yml:96 finding and the v1.26.0 disposition
unchanged.

Source: Path instructions

…nine

Review round two. Two applied, one declined.

**`--no-fail-fast` on the golden-vector step.** Real, and verified by
injection rather than taken on description: cargo's default stops after
the first failing test BINARY, across binaries and not merely within
one. Injecting a failure into `save_state_backward_compat` and running it
alongside `save_state_determinism` showed the second suite never ran at
all. With ten suites in this step that means one broken golden hides the
status of the other nine — an author fixes one, pushes, and only then
discovers the next, serialising a debug loop over a ~6-minute job.

Fixed with `--no-fail-fast` rather than the suggested split into ten
separate steps: one flag against ten cargo startups and ten log sections,
and re-verified that the step still FAILS the job while reporting every
suite.

**`DEFAULT_RUN_AHEAD_THROTTLE_MS`.** The `14.0` is now a named public
constant so the value, the doc comment justifying it, and the test that
bounds it cannot drift apart; the test asserts against the constant
instead of restating the literal.

**Declined: "the CHANGELOG entry is too verbose, keep it focused on
user-facing behaviour."** This project's CHANGELOG is not a summary — per
`docs/adr/0007` the annotated tag body IS the CHANGELOG section verbatim,
and `release-auto.yml` reuses it as the GitHub release notes with no
separate hand-authored text. Entries are therefore written at
release-note quality by design, and the measurements here are the whole
justification for a decision (run-ahead staying opt-in) that would
otherwise read as an unexplained non-change. Trimming to "user-facing
behaviour" would leave the release notes asserting a conclusion with its
evidence deleted.

Verified: `cargo fmt --check`, frontend clippy `-D warnings`, `cargo doc`
with warnings-as-errors, `config::tests` 13/13, ci.yml parses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@doublegate

Copy link
Copy Markdown
Owner Author

Review adjudication — round two

Three findings. Two applied, one declined.

Applied: one failing suite hid the other nine

Real, and worse in practice than described — verified by injection rather than taken on description. Cargo's default stops after the first failing test binary, and it stops across binaries, not merely within one:

$ # failure injected into save_state_backward_compat
$ cargo test -p rustysnes-test-harness --features test-roms \
    --test save_state_backward_compat --test save_state_determinism
     Running tests/save_state_backward_compat.rs
test result: FAILED. 2 passed; 1 failed
error: test failed, to rerun pass `--test save_state_backward_compat`
                                   <- save_state_determinism never ran

With ten suites in that step, one broken golden hides the status of the other nine: an author fixes one, pushes, waits ~6 minutes, and only then discovers the next.

Fixed with --no-fail-fast rather than the suggested split into ten separate steps — one flag against ten cargo startups and ten log sections to read. Re-verified that both suites now report and the step still fails the job:

$ cargo test ... --no-fail-fast --test save_state_backward_compat --test save_state_determinism
     Running tests/save_state_backward_compat.rs
test result: FAILED. 2 passed; 1 failed
     Running tests/save_state_determinism.rs
test result: ok. 3 passed; 0 failed
error: 1 target failed:

The injected test was reverted from a snapshot; git diff on the harness is clean.

Applied: name the 14.0

Now pub const DEFAULT_RUN_AHEAD_THROTTLE_MS: f32 = 14.0, referenced by Default, the doc comment, and the test — so the value and its justification cannot drift apart.

Declined: "the CHANGELOG entry is too verbose; keep it focused on user-facing behaviour"

This project's CHANGELOG is not a summary. Per docs/adr/0007, the annotated tag body is the CHANGELOG section verbatim, and release-auto.yml reuses it as the GitHub release notes with no separate hand-authored text. Entries are written at release-note quality by design.

The measurements are also load-bearing here specifically: they are the entire justification for a decision — run-ahead staying opt-in — that would otherwise read as an unexplained non-change. Trimming to "user-facing behaviour" would leave the release notes asserting a conclusion with its evidence deleted, which is the opposite of what this entry is for.

Verification

cargo fmt --all --check, frontend clippy -D warnings, cargo doc with warnings-as-errors, config::tests 13/13, ci.yml parses.

… asserted

Review round three. One applied, two declined with measurements — and the
PAL suggestion is worth recording because I applied it first and it was
wrong.

**Applied: the absolute timings are host-specific.** The CHANGELOG now
says so, and says why they are there anyway: the ratios are what decide
run-ahead's default, and a reader can only check a ratio by re-running
`save_state_cost` + `headless_frame` and comparing. The percentages are
the portable part; the microseconds are the working.

**Declined: add a matching `Region::Pal` assertion.** I applied this,
then checked the arithmetic before pushing: NTSC's frame budget is
16.64 ms and PAL's is 20.00 ms, so PAL is the LOOSER bound by 3.36 ms
and `throttle_ms < NTSC` strictly implies `throttle_ms < PAL`. The added
assertion could never fail while the existing one passed — a vacuous
assertion sitting beside a real one and reading like extra coverage.
That is the exact shape `CLAUDE.md` names under "a guard must not subsume
the assertion it protects", and the project already has four recorded
instances of the vacuous-test trap. Reverted, with the arithmetic left in
the test so the next reader does not re-derive it.

(My first pass also wrote a comment claiming NTSC was the bound that
would "silently carry a PAL-violating value through", which is backwards
— a PAL-violating value exceeds the NTSC bound too and is caught. Fixed.)

**Declined: merge the two harness `cargo test` steps.** Measured: a second
cargo invocation against an already-built target dir costs 0.58 s — 0.16%
of the ~6-minute step. Against that, merging would apply the battery
step's `-- --nocapture` to all ten golden suites (flooding the log) and
collapse two separately-attributed CI steps into one, so a failure no
longer says at a glance whether the battery or the goldens broke.

Verified: `cargo fmt --check`, frontend clippy `-D warnings`,
`config::tests` 13/13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@doublegate

Copy link
Copy Markdown
Owner Author

Review adjudication — round three

Three findings. One applied, two declined with measurements. The PAL one is worth reading — I applied it first, then found it was wrong.

Declined: add a matching Region::Pal assertion

I applied this, then checked the arithmetic before pushing:

region frame rate budget
NTSC 60.0988 Hz 16.64 ms
PAL 50.00698 Hz 20.00 ms

PAL is the looser bound, by 3.36 ms. So throttle_ms < NTSC strictly implies throttle_ms < PAL — the added assertion can never fail while the existing one passes. It would be a vacuous assertion sitting next to a real one and reading like extra coverage.

That's the exact shape CLAUDE.md names under "a guard must not subsume the assertion it protects", and this project already has four recorded instances of the vacuous-test trap. Reverted, with the arithmetic left in the test so the next reader doesn't re-derive it.

Worth admitting the near-miss: my first pass also wrote a comment claiming NTSC was the bound that would "silently carry a PAL-violating value through" — which is backwards. A PAL-violating value (>20 ms) exceeds the NTSC bound too and is already caught. Fixed.

The underlying observation was still useful: the constant's doc justifies itself against both regions, and now the test explains why only one of them is checkable.

Declined: merge the two harness cargo test steps

Measured — a second cargo invocation against an already-built target dir:

second invocation (no-op freshness check): 0.58 s

0.16% of the ~6-minute step. Against that saving, merging would:

  • apply the battery step's -- --nocapture to all ten golden suites, flooding the log with output the battery specifically wants and the goldens don't;
  • collapse two separately-attributed CI steps into one, so a failure no longer says at a glance whether the battery or the goldens broke.

Applied: the timings are host-specific

Fair. The CHANGELOG now says so — and says why they're recorded anyway: the ratios are what decide run-ahead's default, and a reader can only check a ratio by re-running the same two benches (save_state_cost, headless_frame) and comparing. The percentages are the portable part; the microseconds are the working.

Verification

cargo fmt --all --check, frontend clippy -D warnings, config::tests 13/13.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 39-42: Update the benchmark explanation near the “Absolute
timings” text to remove the claim that percentages are portable. State that the
measurements support the decision only for the benchmark machine, and that both
save_state_cost and headless_frame must be rerun when evaluating another target.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2b4808b4-2fc1-4d9b-8b0e-27aefbe02ae9

📥 Commits

Reviewing files that changed from the base of the PR and between c0f4a58 and 7537536.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • CHANGELOG.md
  • crates/rustysnes-frontend/src/config.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (4)
  • GitHub Check: accuracysnes
  • GitHub Check: test-light
  • GitHub Check: lint
  • GitHub Check: build demo + docs
🧰 Additional context used
📓 Path-based instructions (11)
.github/workflows/**

⚙️ CodeRabbit configuration file

.github/workflows/**: Flag third-party actions pinned to a branch rather than a tag or commit SHA, jobs without
a least-privilege permissions: block, and secrets that could reach the log. The
ci-success job is the required check and must depend on every gate it claims to
aggregate — a new job that is not in its needs: list is a gate nothing enforces.

Files:

  • .github/workflows/ci.yml
**/*.{rs,md}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Chip-behavior changes must update both the chip implementation and the corresponding docs/<subsystem>.md documentation.

A chip change must update both the chip implementation and its corresponding docs/<chip>.md documentation in the same change.

Files:

  • CHANGELOG.md
  • crates/rustysnes-frontend/src/config.rs
CHANGELOG.md

📄 CodeRabbit inference engine (CONTRIBUTING.md)

User-visible changes must be recorded under the [Unreleased] section.

For the full pull request diff against its base branch, modify CHANGELOG.md when user-visible behavior changes, including emulator output, frontend features, CLI flags, public APIs, or AccuracySNES cartridge contents. Do not require it for purely internal changes, tests, comments, or CI configuration.

Files:

  • CHANGELOG.md
**/*

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*: Do not commit or vendor the generated snesdev_wiki/ mirror; it is gitignored and intended only as a local reference.
Keep commits focused and use Conventional Commits: <type>(<scope>): <subject>, with an imperative subject of at most 72 characters.
Do not use emojis in code, comments, or commit messages.
Before opening a PR, ensure formatting, Clippy, workspace tests, the core embedded build, rustdoc with warnings denied, documentation coverage, and changelog requirements pass.
Ticket completion must be reflected in the relevant to-dos/ sprint file.

**/*: Preserve the one-directional crate graph: chip crates must not depend on one another; rustysnes-core ties them together.
Never commit commercial ROMs; only commit derived screenshots and hashes.
Keep docs/STATUS.md as the authoritative per-subsystem status and update project documentation in the same PR as code changes.
Do not treat RustyNES v2.0 or engine-lineage anchors as project releases.

Files:

  • CHANGELOG.md
  • crates/rustysnes-frontend/src/config.rs
**/*.md

⚙️ CodeRabbit configuration file

**/*.md: Docs are the spec, not a changelog. Flag prose that has drifted from the code it describes
rather than style nits. The markdownlint gate is pinned to v0.39.0 via pre-commit —
do not report rules that version does not have (MD060 in particular).

Files:

  • CHANGELOG.md
crates/**/*.rs

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

crates/**/*.rs: Preserve the master-clock lockstep timing model.
rustysnes-core::Bus owns mutable machine state, and the CPU borrows &mut Bus.
Preserve determinism: seed, ROM, and input must produce bit-identical output.
Treat test ROMs as the behavioral specification; when documentation disagrees with passing ROM behavior, update the documentation.
Keep unsafe confined to existing allowed areas, namely frontend and FFI code, and document every unsafe block with a // SAFETY: comment.

Files:

  • crates/rustysnes-frontend/src/config.rs
**/*.rs

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*.rs: Use Rust edition 2024 and the toolchain pinned in rust-toolchain.toml (Rust 1.96).
Run cargo fmt --all --check; Rust code must remain rustfmt-compliant.
Run Clippy with cargo clippy --workspace --all-targets -- -D warnings; warnings must not remain.
New public Rust items must have rustdoc because missing_docs is a workspace lint.
Do not run cargo clippy --all-features; scripting and script-wasm are mutually exclusive. Use explicit per-feature jobs instead.

**/*.rs: Do not introduce .unwrap(), .expect(), or panic!() on untrusted external input—such as ROM/save-state bytes, netplay messages, Lua or scripting input, or user-supplied paths—outside #[cfg(test)] code. Use typed errors at those boundaries; locally constructed values or values immediately protected by a checked invariant are allowed.
Every new unsafe { ... } block or unsafe fn must have an adjacent // SAFETY: comment naming the relied-on invariant and its guarantor. Unsafe code outside the frontend and FFI shims should additionally be questioned because unsafe_code is a workspace lint.

**/*.rs: Use Rust edition 2024 with the pinned 1.96 toolchain; satisfy workspace pedantic, nursery, missing_docs, and unsafe_code warnings because CI runs with -D warnings. Document every public item.
Keep unsafe code restricted to the frontend and FFI, and include a // SAFETY: justification for each use.
Keep hot paths allocation-free.
Treat rustysnes_core::Bus as the owner of mutable emulator state; the CPU borrows &mut Bus.
Use the master clock at 21477270 Hz as the timing master; advance the scheduler in lockstep and run other chips on their divisors.
Maintain determinism: seed, ROM, and input must produce bit-identical audio/video; frontend rate control must not alter emulation results.
When implementing hardware behavior, pin and run the failing test ROM first; treat test ROMs as the specification.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/rustysnes-*/**/*

📄 CodeRabbit inference engine (Custom checks)

For the full pull request diff against its base branch, any observable behavior change under crates/rustysnes-<chip>/ must be accompanied by an edit to the matching docs/<chip>.md; a crate change passes without documentation only when it does not alter observable behavior, with the non-behavioral change stated explicitly.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/rustysnes-frontend/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

Frontend code is one of the permitted locations for unsafe; any unsafe use still requires a // SAFETY: comment.

Files:

  • crates/rustysnes-frontend/src/config.rs
**/*.{rs,toml}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{rs,toml}: Additive features must be default-off so shipped/native, no_std, and wasm builds remain byte-identical.
Never use or configure --all-features; validate opt-in feature combinations individually as required by the project recipe.

Files:

  • crates/rustysnes-frontend/src/config.rs
crates/**

⚙️ CodeRabbit configuration file

crates/**: Emulator core. Hot paths are allocation-free; unsafe requires a // SAFETY: comment
naming the invariant. Any change to save-stated fields needs a FORMAT_VERSION bump and a
docs/adr/0006 bump-log entry. Behavior changes must update the matching docs/<chip>.md
in the same change.

Files:

  • crates/rustysnes-frontend/src/config.rs
🔇 Additional comments (7)
.github/workflows/ci.yml (2)

359-366: LGTM!


367-379: 🔒 Security & Privacy

No permissions change is required. The workflow-level permissions: contents: read applies to accuracysnes, and the job does not override it.

			> Likely an incorrect or invalid review comment.
CHANGELOG.md (2)

12-32: LGTM!


58-64: LGTM!

crates/rustysnes-frontend/src/config.rs (3)

928-982: Pin the documented 14.0 ms value in both default assertions.

Line [937] only checks that RunAheadConfig::default() matches its own constant. Line [980] only checks that an omitted value is positive. A future positive default can therefore change the documented behavior without failing the tests. Assert the exact 14.0 value for both paths, using an exact comparison that remains Clippy-compliant.

Proposed test adjustment
-        assert!(cfg.throttle_ms == DEFAULT_RUN_AHEAD_THROTTLE_MS);
+        assert_eq!(cfg.throttle_ms.to_bits(), 14.0_f32.to_bits());
...
-        assert!(cfg.run_ahead.throttle_ms > 0.0);
+        assert_eq!(cfg.run_ahead.throttle_ms.to_bits(), 14.0_f32.to_bits());

617-622: LGTM!


637-639: 🩺 Stability & Availability

No change is required. The run-ahead branch requires config.run_ahead.frames > 0, so the default throttle_ms does not affect pacing when run-ahead is disabled.

			> Likely an incorrect or invalid review comment.

Comment thread CHANGELOG.md
Comment on lines +39 to +42
Absolute timings below are from one development machine and will differ on other hardware — they
are recorded because the **ratios** are what decide this, and a reader can only check a ratio by
re-running the same two benches (`save_state_cost`, `headless_frame`) and comparing. The
percentages are the portable part; the microseconds are the working:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not describe host-dependent percentages as portable.

2.4%, 79%, and 118% are calculated from host-specific benchmark times. A faster or slower host changes those percentages. State that the measurements support the decision for the benchmark machine, and require rerunning the benches for another target.

As per path instructions, Markdown prose must match the behavior and evidence it describes.

Suggested wording
- The percentages are the portable part; the microseconds are the working:
+ These percentages describe the benchmark machine; rerun the same benches on target hardware before changing the default:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Absolute timings below are from one development machine and will differ on other hardware — they
are recorded because the **ratios** are what decide this, and a reader can only check a ratio by
re-running the same two benches (`save_state_cost`, `headless_frame`) and comparing. The
percentages are the portable part; the microseconds are the working:
Absolute timings below are from one development machine and will differ on other hardware — they
are recorded because the **ratios** are what decide this, and a reader can only check a ratio by
re-running the same two benches (`save_state_cost`, `headless_frame`) and comparing. These
percentages describe the benchmark machine; rerun the same benches on target hardware before changing the default:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 39 - 42, Update the benchmark explanation near the
“Absolute timings” text to remove the claim that percentages are portable. State
that the measurements support the decision only for the benchmark machine, and
that both save_state_cost and headless_frame must be rerun when evaluating
another target.

Source: Path instructions

Review round four, nitpick applied: the `assert!(x == y)` at the default
check lacked the note its sibling assertion carries about why
`assert_eq!` fails clippy's `float_cmp` under `-D warnings`. Two
identical-looking lines where only one explains itself is how the next
person "simplifies" the unexplained one and breaks the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@doublegate doublegate changed the title ci(test): run the goldens on PRs; measure run-ahead instead of assuming ci(test): gate the goldens on PRs; keep run-ahead opt-in on measurement Jul 31, 2026
@doublegate

Copy link
Copy Markdown
Owner Author

Review adjudication — round four

Two findings. One applied, one partly applied.

Applied: float-comparison comment parity

Right, and the reason it matters is specific: two identical-looking assert!(x == y) lines where only one explains itself is exactly how the next person "simplifies" the unexplained one to assert_eq! and breaks the -D warnings gate. Both now carry the note.

Partly applied: commit scope

The observation is fair — ci(test): under-described a change that also touches RunAheadConfig::default(), docs/frontend.md, and the roadmap. Title corrected to ci(test): gate the goldens on PRs; keep run-ahead opt-in on measurement, which names both halves (71 chars, inside the limit).

Not split into separate commits, for two reasons:

  1. This PR squash-merges, so the branch's four commits collapse into one anyway — the PR title is the commit message. Splitting the branch would produce history that never reaches main.
  2. The pieces are not independent. The RunAheadConfig::default() change exists because of the measurement, and the measurement is what the docs record. Landing the config change alone would be a default flip with no stated justification; landing the docs alone would document a behaviour the code doesn't have. "One logical change" is satisfied here at the level the change actually has — closing this rung's third item — rather than at file granularity.

Where the guidance does bite is if these were going in as separate commits on main. They aren't.

Verification

cargo fmt --all --check, frontend clippy -D warnings, config::tests 13/13.

@github-actions

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR adds golden vector and save-state test targets to the pull request CI workflow, sets RunAheadConfig's Default to arm the throttle_ms budget limit at 14.0 ms while keeping run-ahead opt-in (frames = 0), and updates lockstep tracking documentation.

Blocking issues

None found.

Suggestions

  • ci.yml:L338-L377: The new "Run the golden vectors" step executes ten test targets with --no-fail-fast, whereas the preceding step executes --test accuracysnes separately without --no-fail-fast. Consolidate --test accuracysnes into the golden vector test invocation so all test-harness suites share identical --no-fail-fast reporting behavior and avoid duplicate cargo test overhead.
  • config.rs:L937-L940: The test relies on direct float comparison (assert!(cfg.throttle_ms == DEFAULT_RUN_AHEAD_THROTTLE_MS)) specifically to avoid Clippy's float_cmp lint on assert_eq!. Prefer comparing using f32::EPSILON (or (a - b).abs() < f32::EPSILON) to make floating-point assertions robust against representation nuances without triggering lints.

Nitpicks

  • CHANGELOG.md:L14: Descriptions under [Unreleased] refer to v1.26.0 in the past tense. Keep items under [Unreleased] phrased without pinning the unreleased version number in text.

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

@doublegate

Copy link
Copy Markdown
Owner Author

Review adjudication — round five

Three findings, all declined with measurements. Two of them re-raise points already answered; one contradicts this reviewer's own round-two advice.

Declined: consolidate the battery step into the golden step

Re-raised from round three, where it was declined at 0.58 s measured overhead (0.16% of a ~6-minute step) against losing per-step failure attribution and flooding ten suites with the battery's -- --nocapture.

The new angle — that the two steps have "different --no-fail-fast reporting behavior" — is not a real difference, and that is checkable. Within a single test binary, libtest runs every test regardless of failures; --no-fail-fast only governs whether cargo proceeds to the next binary. Injected two tests into one suite to confirm:

test injected_b_also_runs ... ok        <- ran anyway
test injected_a_fails ... FAILED
test result: FAILED. 4 passed; 1 failed

The battery step runs exactly one target, so the flag would be a no-op there. There is no reporting inconsistency to reconcile. (Injected tests reverted; harness diff clean.)

Declined: use f32::EPSILON instead of ==

This contradicts round two from this same reviewer, which asked for the opposite — "checking (x - 0.0).abs() < f32::EPSILON is unnecessarily verbose... use == 0.0 directly" — and which I applied and verified against clippy.

On the merits, == is the correct comparison here and epsilon would be actively wrong. Both assertions compare a value to the exact constant it was constructed from, with no arithmetic in between: RunAheadConfig::default() assigns DEFAULT_RUN_AHEAD_THROTTLE_MS, and the TOML case round-trips an exact literal. There are no representation nuances to be robust against. An epsilon window would instead mask a real regression — a default changed to 14.0000001 would pass.

Epsilon comparison is for values that have been computed. These haven't.

Declined: don't name v1.26.0 inside [Unreleased]

Checked against this project's actual conventions rather than the general principle:

  • The number is right. gh release list confirms v1.25.0 is Latest (2026-07-30) and git tag agrees, so the next minor is v1.26.0. This project has a recorded gotcha about baking an unreleased label in before verifying exactly this — verification done.
  • Inline version-labelling is the house style. v1.25.0, v1.21.0, v1.20.0 and friends appear throughout crates/rustysnes-frontend/src/*.rs, docs/, and prior CHANGELOG entries as the standard way of dating a change. Stripping them from this entry alone would make it the inconsistent one.
  • The [Unreleased] heading is renamed to the version at release (release-auto.yml keys off exactly that), so the prose and the heading converge rather than diverge.

Verification

cargo fmt --all --check, frontend clippy -D warnings, config::tests 13/13, all checks green including ci-success.

@doublegate
doublegate merged commit ff9379e into main Jul 31, 2026
16 checks passed
@doublegate
doublegate deleted the feat/tripwire-runahead-lockstep branch July 31, 2026 06:33
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.

1 participant