Skip to content

chore(release): cut v2.5.4 "Escapement" — the background fetch pipeline, and an access two dots early that five gates could not see - #450

Merged
doublegate merged 11 commits into
mainfrom
chore/release-v2.5.4-escapement
Aug 23, 2026
Merged

chore(release): cut v2.5.4 "Escapement" — the background fetch pipeline, and an access two dots early that five gates could not see#450
doublegate merged 11 commits into
mainfrom
chore/release-v2.5.4-escapement

Conversation

@doublegate

Copy link
Copy Markdown
Owner

v2.5.4 "Escapement"

The background fetch pipeline, and an access two dots early that five gates could not see.

An escapement is the part of a clock that releases the gear one step at a time. It decides when, not how much — which is exactly what this release got wrong and then right.

Rung 3 gains its background fetch pipeline

rtl/ppu2c02.sv now issues nametable, attribute, pattern-low and pattern-high fetches on the documented 8-dot cadence, and the comparison against RustyNES is an address-bus trace. A 2C02 drives A0–A13 on real pins, so a logic analyser could capture the same sequence and two correct implementations cannot disagree about it. That is what makes it a gate rather than a diagnostic under docs/rung3-ppu.md's partition — unlike the latches behind it, which are this project's decomposition of what was fetched.

reference: 6247 background fetches (811 outside the window, not compared)
actual:    6247 background fetches (0 outside the window, not compared)
All 6247 compared fetches match on scanline, dot and address.

Eight mutations, all CAUGHT, with the baseline verified passing before the batch and three outcomes classified rather than two — one mutant did build-fail on a width mismatch and was replaced with a same-width one rather than counted.

The comparison is narrowed: a rendering scanline issues 154 fetches and this gate compares the 136 background ones (dots 1–256 and 321–336), excluding sprite fetches (257–320, v2.5.7) and the two dummy nametable reads (337–340, v2.5.8). Every run prints how many records each side dropped — 811 and 0 today — because a narrowing that is not announced reads as full coverage.

The fault was in the testbench, and it was two dots

The DUT issued one extra nametable fetch at the leading edge of each rendering window and dropped one at the trailing edge, both by exactly two dots. One quantity, wrong by one constant, at both edges — not two faults.

tb/cpu_main.cpp splits each CPU cycle into three PPU dots, 2 + 1 around the boot record, and it presented the CPU access on the second dot, alongside the record. A 6502 commits a write and samples a read at φ2, the end of the cycle — the third dot. So enabling rendering through $2001 took effect two dots early, and so did disabling it.

Five gates stayed green across the move, in both directions: rung 1's register comparison on nine opcode ROMs, rung 2's per-cycle bus comparison, the interrupt sweep, and the v2.5.2 register and v2.5.3 scroll gates. That is not evidence the shift was harmless. Every one of them reads state once per CPU cycle, so a uniform two-dot shift in when a write lands inside that cycle moves nothing any of them compare. This is the rung's first gate keyed to the dot counter, and the first that could see it.

This does not reinstate the pairing rejected at v2.5.3. That attempt moved the access and shifted power-on together — two knobs for one quantity. Power-on alignment is untouched and the record point still sits at two dots; only the access moved, and it moved to where the hardware puts it.

nestest's verified window more than doubled

Rung 2's nestest gate was bounded at 27,396 cycles by a missing peripheral, not by a CPU defect: nestest reads $2002 there, and through v2.5.3 the testbench answered from flat memory while the oracle returned $80 with vblank set. With the register file answering, the DUT matches on pc, bus_addr, bus_data and bus_access for 59,554 cycles — 2.18× the old extent.

59,562 is where the two-frame golden ends, not where agreement ends. The bound is now an artifact budget rather than a wall.

Two mutations that were right to fail

The first mutation batch returned two NOT CAUGHT against a gate that was working correctly:

  • v[11:0] → v[10:0] on the tile address — v[11] is nametable-Y, and the ROM
    rendered from $2000 throughout.
  • ctrl[4] → 1'b0 on the pattern-table select — the ROM wrote PPUCTRL = 0.

Both were no-ops for this stimulus. The gate was not blind; the ROM was. A bit a ROM never sets is a bit no comparison over that ROM can adjudicate, however many records it holds — the same shape as v2.5.1's BRK passing 186/186 while the interrupt return address was wrong.

ppufetch.nes gained a second rendering window at $2800 with background patterns at $1000. PPUCTRL is written before the $2006 pair on purpose: PPUCTRL bits 0–1 write t[11:10] and the $2006 high byte then overwrites all of t, so setting the nametable through PPUCTRL alone would be silently discarded. Fetch count 3,099 → 6,247, and both mutations to CAUGHT.

Oracle side: four trace features had never been linted

No workflow named cpu-boot-trace, irq-timing-trace, ppu-state-trace or the new ppu-fetch-trace. --workspace --all-targets reaches each crate's default feature set only, and the rustynes-cosim clippy step lints that package while compiling rustynes-core and rustynes-ppu as dependencies, where warnings are not denied. So every #[cfg(feature = "…-trace")] module in the chip crates was unlinted — the same gap v2.4.1 found six errors in.

CI gains one explicit step per feature, named individually so a failure says which. ppu-state-trace had six -D warnings errors waiting in it, including a genuine dead-code case: that feature compiles out the v2.2.3 fast dot path so the trace hook can observe every dot, which orphans tick_visible_render_fast under that feature and only that feature — live by default, dead under one flag, which is the one shape that earns a scoped allow rather than a deletion.

Also fixed: nes_golden_export's usage text, which documented neither --fetch-trace nor any of the four injection flags added at v2.5.1.

Verified, not asserted

rustynes-ppu changed, so the accuracy battery was run rather than claimed:

  • AccuracyCoin 141/141 (100.00%, RAM decoder)
  • nestest 0-diff

What v2.5.4 does not pin

Sprite fetches (v2.5.7), the two dummy nametable reads at dots 337–340 (v2.5.8), the byte returned by each fetch, the latch-to-shift cascade and fine-X (diagnostic — the pixel is the gate, and that is v2.5.5), and sub-dot placement within a two-dot access, for which this comparison has no resolution to offer.

doublegate and others added 3 commits August 23, 2026 12:25
v2.5.4's compare surface, oracle side. Behind `ppu-fetch-trace`, default
off, enabled only by the excluded `rustynes-cosim` crate.

## Why capture rather than derive

A 2C02 drives A0-A13 and /RD on real pins. The sequence of addresses it
asks for, dot by dot, is a HARDWARE FACT -- a logic analyser can record
it, and two correct implementations may not disagree about it. That is
what makes it usable as a gate against an independent reimplementation,
unlike the internal latches, which are one decomposition among several
and stay diagnostic.

The address is derivable from `v` and PPUCTRL, both of which the state
trace already carries. Deriving it inside the comparator would reimplement
the addressing arithmetic that is the SUBJECT of the comparison, and a
test that reimplements its subject agrees with itself forever. So it is
captured where the PPU actually asks for it.

## One choke point, not many call sites

The hook is in `read_vram`, which every PPU bus read already passes
through: pattern fetches, nametable and attribute fetches, sprite pattern
fetches, and `$2007`. Capturing there rather than at each fetch site is
what makes the trace complete BY CONSTRUCTION -- a fetch added later
cannot forget to record itself.

## A truncated window says so

A full buffer stops recording and counts what it dropped, and the exporter
warns on a non-zero count. It does not wrap: a wrapped buffer silently
changes WHICH window a comparison covers, which is the quiet coverage loss
this project keeps finding. The count is printed rather than folded into
the manifest where nobody looks.

## The prediction the doc made, confirmed by the first run

`docs/rung3-ppu.md` recorded in advance that this step's specific way of
passing while testing nothing is that "a fetch-address comparison passes
trivially if rendering never turns on". The first export measures 459
fetches across two frames of `ppuscroll` -- against roughly 34,000 per
rendering frame -- because that ROM enables rendering for two short
windows and nothing else.

So v2.5.4 needs a sustained-rendering ROM, and the comparator needs the
non-zero-fetch assertion the doc already specifies. Recorded here because
the prediction was written down BEFORE the measurement, which is the only
form in which one is worth anything.

## Verified

`rustynes-ppu` changes, so the numbers are VERIFIED rather than inherited
even though the feature is default-off and the hook compiles to nothing
without it: AccuracyCoin 141/141 (100.00%, RAM decoder), nestest 0-diff,
workspace 2239 passed / 130 suites / 0 failed. Clippy clean on the
workspace, on `rustynes-ppu` and `rustynes-core` with the feature ON, and
on the cosim crate; `no_std` cross-build still clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The audit caught the new PPU field immediately -- a field must be either
serialized or listed as deliberately excluded, and this one was neither.
It is doing exactly what it exists for.

`fetch_trace` is output-only telemetry: nothing in the PPU consults it, so
a restore that discards it cannot change a single dot. That is one of the
three categories the audit accepts, and it is recorded with that reasoning
rather than a bare name -- the file's own docs warn that the default answer
is to SERIALIZE and that it has been right three times out of three, so an
exclusion needs to say why it is not one of those.

## And a process failure worth naming

I committed the previous change while this test was FAILING. The workspace
run printed "suites: 103 passed: 2234 failed: 1" and I read the commit SHA
on the next line instead of the failure count on that one. The gate was
not broken and nothing was hidden -- I simply did not look.

The number that mattered was in the output I asked for, one field to the
left of the one I read. Worth recording because the fix is not more gates:
it is reading the result of the gate that already ran.

Gates: workspace 2240 passed / 130 suites / 0 failed; clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…ne, and an access two dots early that five gates could not see

An escapement is the part of a clock that releases the gear one step at a time.
It decides WHEN, not how much — which is exactly what this release got wrong and
then right.

RUNG 3 GAINS ITS BACKGROUND FETCH PIPELINE

The sibling core (RustyNES_MiSTer@ed0c36a) issues nametable, attribute,
pattern-low and pattern-high fetches on the documented 8-dot cadence, and the
comparison against this emulator is an ADDRESS-BUS trace: a 2C02 drives A0-A13
on real pins, so a logic analyser could capture the same sequence and two
correct implementations cannot disagree about it. That is what makes it a gate
rather than a diagnostic under the rung's partition — unlike the latches behind
it, which are this project's decomposition of what was fetched.

6,247 background fetches, 0 divergences on scanline, dot and address across two
rendering windows. Eight mutations, all CAUGHT, baseline verified passing first.

The oracle side of that is `ppu-fetch-trace`, a default-off feature on
`rustynes-ppu` and `rustynes-core` recording the address of every PPU VRAM read
with its frame, scanline and dot. The hook sits at the single choke point,
`Ppu::read_vram`, and not at each call site — a per-site hook is how a fetch
path added later escapes the trace silently. Output-only, and deliberately
outside the save state, so `snapshot_schema_audit` carries it as an explicit
exclusion with its reason.

THE FINDING: AN ACCESS TWO DOTS EARLY THAT NO EXISTING GATE COULD SEE

The DUT issued one extra nametable fetch at the leading edge of each rendering
window and dropped one at the trailing edge, both by exactly two dots. One
quantity, wrong by one constant, at both edges — not two faults.

The cause was in the testbench. A 6502 commits a write and samples a read at
phi2, the last of the cycle's three PPU dots; the harness presented every access
on the second, alongside the boot record. So enabling rendering through $2001
took effect two dots early, and so did disabling it.

Five gates stayed green across the move, IN BOTH DIRECTIONS: rung 1's registers
on nine opcode ROMs, rung 2's per-cycle bus, the interrupt sweep, and the v2.5.2
register and v2.5.3 scroll gates. That is not evidence the shift was harmless.
Every one of them reads state once per CPU CYCLE, so a uniform two-dot shift in
when a write lands inside that cycle moves nothing any of them compare. This is
the rung's first gate keyed to the DOT counter, and the first that could see it.
The lesson is recorded in AGENTS.md: when a defect is invisible to every
existing gate, ask what RESOLUTION those gates sample at before concluding it
does not matter.

NESTEST'S VERIFIED WINDOW MORE THAN DOUBLED, 27,388 -> 59,554 CYCLES

Rung 2's nestest gate was bounded by a MISSING PERIPHERAL rather than a CPU
defect: nestest reads $2002 at cycle 27,396 and the DUT had no PPU to answer.
With the register file answering it matches for 59,554 cycles, 2.18x the old
extent — and the bound is now the two-frame golden's length, an artifact budget
rather than a wall.

TWO MUTATIONS THAT INDICTED THE STIMULUS, NOT THE GATE

The first batch returned two NOT CAUGHT against a gate that was working
correctly, because the ROM rendered from $2000 with PPUCTRL = 0 and so held
v[11] and ctrl[4] at zero throughout. The gate was not blind; the ROM was. A
second rendering window at $2800 with background patterns at $1000 took the
fetch count 3,099 -> 6,247 and both mutations to CAUGHT.

FOUR TRACE FEATURES HAD NEVER BEEN LINTED

No workflow named `cpu-boot-trace`, `irq-timing-trace`, `ppu-state-trace` or the
new `ppu-fetch-trace`. `--workspace --all-targets` reaches each crate's DEFAULT
feature set only, and the `rustynes-cosim` clippy step lints that package while
compiling `rustynes-core` and `rustynes-ppu` as DEPENDENCIES, where warnings are
not denied — the same gap v2.4.1 found six errors in. CI gains one explicit step
per feature, named individually so a failure says which.

`ppu-state-trace` held six `-D warnings` errors, one of them a legitimate
dead-code case: that feature compiles out the v2.2.3 fast dot path so the trace
hook can observe every dot, which orphans `tick_visible_render_fast` under that
feature and only that feature. Live by default, dead under one flag — the one
shape that earns a scoped `allow` rather than a deletion, and it is scoped to
that feature so it cannot start suppressing a real finding in the default build.

Also fixed: `nes_golden_export`'s usage text, which documented neither
`--fetch-trace` nor any of the four injection flags added at v2.5.1.

VERIFIED, NOT ASSERTED

`rustynes-ppu` changes, so the accuracy battery was run rather than claimed:

    AccuracyCoin (RAM): pass rate = 100.00% over 141 assigned tests
    nestest_pc_c000_matches_golden_log ... ok

Also green: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets`,
the four new per-feature clippy steps, `cargo test --workspace --release`, the
`thumbv7em-none-eabihf` no_std build, the excluded `rustynes-cosim` crate's own
fmt/clippy/test, and all five release audits.
Copilot AI lite review requested due to automatic review settings August 23, 2026 18:06
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

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: c2a309ad-c9ef-43f0-81fc-942bb868b046

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/release-v2.5.4-escapement

Warning

Your free Security trial is over. An organization admin can activate billing to continue.


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.

@doublegate

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Head commit changed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

`docs/STATUS.md` is this project's single source of truth for CURRENT state, and
its most recent rung note was "Rung 1 as of v2.4.8" -- correct as a dated claim
and three rung steps stale as a current one. Rung 3 now has three closed steps
and a reader of the authoritative status file could not tell.

Follows the shape of the existing rung-1 note, including the half that matters:
what the rung does NOT verify, stated rather than implied. Sprite fetches are
excluded by construction, the two dummy nametable reads with them, and the
latch-to-shift cascade and fine-X stay diagnostic -- the pixel is the gate, and
that is v2.5.5. `sys/` is still empty and there is no `.rbf`, which is the fact
most easily lost between a green gate and a working core.

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 23, 2026

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR implements a new per-dot PPU background fetch trace for the RTL co-simulation gate, corrects a two-dot alignment issue in the CPU testbench comparison, and adds explicit CI linting for trace feature flags.

Blocking issues

None found.

Suggestions

  • crates/rustynes-test-harness/src/bin/trace_dma_4015.rs:318: You could use prev_cycle.is_some_and(|pc| r.cpu_cycle > pc + 1) instead of a let chain here. It evaluates the exact same condition but reads slightly more naturally for simple boolean checks against an Option.

Nitpicks

  • crates/rustynes-test-harness/src/bin/cpu_boot_trace_diff.rs: Using core::cmp::Ordering is perfectly valid, but standard convention in std-enabled Rust code often prefers std::cmp::Ordering.

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-23 20:02 UTC

Antigravity review (Gemini via Ultra)

Error: timeout waiting for response

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-23 19:57 UTC

Antigravity review (Gemini via Ultra)

This PR cuts the v2.5.4 release, introducing the ppu-fetch-trace feature, clamping limits for the nes_golden_export trace capacity, and updating documentation/CI workflows.

Blocking issues

  • CI build failure / Test panic: crates/rustynes-cosim/tests/fast_path_does_not_bypass_the_fetch_trace.rs hardcodes the ROM path to ../RustyNES_MiSTer/tb/roms/ppufetch.nes. Because RustyNES_MiSTer is a private sibling repository, this will panic and fail the CI step cargo test --manifest-path crates/rustynes-cosim/Cargo.toml if the repository isn't checked out alongside this one. (Consider checking the test ROM into this repository or skipping the test rather than panicking if the sibling directory does not exist).

Suggestions

  • let_chains unstable syntax: In crates/rustynes-ppu/src/state_trace.rs (around line 1599 in the diff), the syntax if let Some(r) = self.scanline_range.as_ref() && !r.contains(&scanline) relies on the let_chains feature. If this is not fully stabilized in the project's MSRV (Rust 1.96), it will break the stable build. Consider splitting this into nested if statements to be safe.
  • Commit format: The commit type chore(release) contains substantive functional changes (a new ppu-fetch-trace feature, logic fixes, and CI additions), which strictly violates Conventional Commits. Extract the feature additions to feat: and fix: commits.

Nitpicks

  • In crates/rustynes-ppu/src/fetch_trace.rs, FetchRecord::from_bytes uses try_into().ok()? on sub-slices. Since buf.len() < RECORD_SIZE already guarantees bounds, try_into().unwrap() is perfectly safe and explicitly communicates to the compiler and reader that the size matches exactly.

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-23 19:51 UTC

Antigravity review (Gemini via Ultra)

This PR cuts the v2.5.4 release, adding a per-dot PPU bus-address capture (ppu-fetch-trace) for co-simulation background-fetch gating, and updates project documentation to match.

Blocking issues

  • Fast-path bypasses trace (Correctness): In crates/rustynes-ppu/src/ppu.rs, the tick_visible_render_fast fast path is explicitly disabled by ppu-state-trace to guarantee accurate per-dot evaluation, but the new ppu-fetch-trace feature fails to disable it. If ppu-fetch-trace is enabled without ppu-state-trace, the fast path will run and bypass per-dot read_vram calls, silently dropping background fetches or recording them with stale self.dot values. The dispatch guarding the fast path must be updated to check #[cfg(not(any(feature = "ppu-state-trace", feature = "ppu-fetch-trace")))].
  • Missing writes in address trace (Correctness): crates/rustynes-ppu/src/ppu.rs instruments Ppu::read_vram with ppu-fetch-trace, but omits Ppu::write_vram. VRAM writes (e.g., via $2007) also drive the physical PPU address bus in hardware. These cycles will be silently dropped from the trace, causing mismatches against an accurate HDL simulation that asserts the address bus on every dot.

Suggestions

  • crates/rustynes-ppu/src/ppu.rs: Update the #[cfg_attr(feature = "ppu-state-trace", allow(dead_code))] attribute above tick_visible_render_fast to use any(feature = "ppu-state-trace", feature = "ppu-fetch-trace") so it doesn't trigger unused code warnings when the fast path is disabled by the new feature.
  • crates/rustynes-ppu/src/fetch_trace.rs: FetchRecord::from_bytes returns an Option<Self>. Consider returning a typed Result<Self, ParseError> to align with the style guide's preference for typed results over options, though Option functions adequately for a simple length check.

Nitpicks

  • The is_multiple_of method used in crates/rustynes-ppu/src/state_trace.rs requires Rust 1.73+. This is fine given the rust-version = "1.96" target, but worth noting if older toolchain compatibility is expected.

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-23 19:23 UTC

Antigravity review (Gemini via Ultra)

Error: timeout waiting for response

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-23 19:00 UTC

Antigravity review (Gemini via Ultra)

This PR cuts the v2.5.4 "Escapement" release, updating documentation and implementing a PPU fetch trace diagnostic to resolve a two-dot timing deviation in the background rendering pipeline.

Blocking issues

None found.

Suggestions

  • crates/rustynes-ppu/src/fetch_trace.rs: In FetchTrace::with_capacity, records is initialized with Vec::with_capacity(capacity.min(1 << 20)), but self.capacity stores the raw capacity value. This means the vector will still reallocate during push if capacity > 1 << 20. Consider clamping self.capacity as well if the goal is to strictly prevent all reallocations.
  • crates/rustynes-ppu/src/fetch_trace.rs: Consider adding a basic round-trip unit test for FetchRecord::to_bytes and from_bytes to prevent regressions in the binary encoding schema.

Nitpicks

  • crates/rustynes-ppu/src/ppu.rs: The .unwrap_or(u32::MAX) on the frame counter safely handles overflow, but could be clearer as a saturating cast if that's the explicit intent.

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-23 18:30 UTC

Antigravity review (Gemini via Ultra)

This PR cuts the v2.5.4 release, introduces a new feature-gated ppu-fetch-trace to capture per-dot PPU bus addresses for co-simulation, and addresses previously unlinted trace features in CI.

Blocking issues

None found.

Suggestions

  • crates/rustynes-ppu/src/fetch_trace.rs: In FetchTrace::with_capacity, limiting the initial allocation via capacity.min(1 << 20) effectively prevents massive upfront allocations. Just be aware that if capacity is much larger than 1 << 20 and the buffer fills up, the Vec will reallocate dynamically multiple times as it grows up to self.capacity. If capacity shouldn't realistically exceed that size, you might want to cap self.capacity directly to prevent accidental OOMs from arbitrarily large CLI arguments.

Nitpicks

  • Inside ppu.rs, handling frame counter overflow via u32::try_from(self.frame).unwrap_or(u32::MAX) is a great touch to safely saturate instead of wrapping or panicking.
  • Refactoring state_trace.rs to use is_multiple_of and Rust 2024 let chains noticeably improves readability.

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-23 18:22 UTC

Antigravity review (Gemini via Ultra)

This PR cuts the v2.5.4 release, adds a ppu-fetch-trace feature to record PPU VRAM accesses, and updates CI to explicitly lint trace features.

Blocking issues

  • Ineffective CI linting for PPU trace features: In .github/workflows/ci.yml (lines 93-96), the cargo clippy steps for ppu-state-trace and ppu-fetch-trace target -p rustynes-core. Because cargo clippy only lints the explicitly targeted package and only typechecks dependencies, rustynes-ppu will remain unlinted. This defeats the stated purpose of these new CI steps.

Suggestions

  • In .github/workflows/ci.yml (lines 94 and 96), change -p rustynes-core to -p rustynes-ppu so that the PPU crate code is actually linted for those features.

Nitpicks

  • None.

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-23 18:12 UTC

Antigravity review (Gemini via Ultra)

This PR implements a new ppu-fetch-trace feature to capture and export the PPU's per-dot address bus activity for co-simulation, and updates the CI to explicitly lint trace-gated features.

Blocking issues

None found.

Suggestions

  • crates/rustynes-cosim/src/bin/nes_golden_export.rs: If dropped > 0, the tool prints a warning to stderr but continues. Since the commit notes state that a truncated window claims coverage it does not have, consider failing the export (e.g., via panic! or std::process::exit(1)) so CI or local runs fail loudly instead of risking the warning being swallowed in standard output.

Nitpicks

  • crates/rustynes-ppu/src/fetch_trace.rs: In FetchRecord::from_bytes, manually indexing slices (e.g., [buf[0], buf[1], buf[2], buf[3]]) is error-prone. Consider using buf[0..4].try_into().unwrap() which is cleaner and safer.

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

…y the clippy target is right

Three review findings from #450, one refuted with a measurement and two taken.

REFUTED: the clippy step does lint rustynes-ppu

The blocking finding said `cargo clippy -p rustynes-core --features ppu-state-trace`
leaves `rustynes-ppu` unlinted, on the general rule that clippy lints only the
selected package and merely typechecks its dependencies. That rule does not hold
for a WORKSPACE MEMBER: cargo runs clippy-driver through
RUSTC_WORKSPACE_WRAPPER, which covers every workspace member in the graph, and
`-D warnings` reaches them.

Measured rather than argued. Reverting one `const fn` fix in
`rustynes-ppu/src/state_trace.rs` makes that exact step fail with "could not
compile `rustynes-ppu` (lib) due to 1 previous error", pointing at
`crates/rustynes-ppu/src/state_trace.rs:590`; restoring it returns zero errors.
That is a mutation with its control, and it settles the question in the
direction the reviewer did not expect.

`-p rustynes-core` is also the BROADER target, because it lints the core's own
feature-gated code as well as the PPU's. Narrowing to `-p rustynes-ppu` would
lose coverage, so the workflow now carries the evidence inline -- the finding was
reasonable and someone will reach the same conclusion again otherwise.

Worth noting for its own sake: this is the same shape as the recurring
"let-chains are unstable" claim AGENTS.md records. A plausible general rule,
stated confidently, that a two-minute experiment disproves. The rule is to run
the experiment BEFORE writing the fix, and it was.

TAKEN: an overflowed fetch trace now fails the export

`write_fetch_trace` warned on dropped records and continued. A truncated golden
is not a smaller golden -- the DUT captures the whole run, so the comparator
sees a length mismatch and reports a divergence whose real cause is an
export-side capacity, several steps away from where it would be read.

Worse, this project has already been bitten by exactly this: AGENTS.md records a
warning that fired on every correct injection run and was never seen, because
the caller redirected stderr. A warning is a signal that may not arrive.

The file is written FIRST and kept -- its records are real, and a partial
capture is still worth inspecting by hand -- then the process exits 1. Verified
in both directions: `--fetch-trace 100` on ppufetch drops 6958 reads and exits
1; `--fetch-trace 200000` on the same ROM exits 0.

TAKEN: `FetchRecord::from_bytes` no longer hand-indexes

`buf[0..4].try_into().ok()?` rather than `[buf[0], buf[1], buf[2], buf[3]]`. The
slice lengths now come from the field widths themselves, so a field that moves
cannot silently read the neighbouring one. It propagates rather than panicking,
which is why there is no `unwrap` here even though the length check above
already makes every conversion infallible.
@doublegate

Copy link
Copy Markdown
Owner Author

Thanks — three findings, one refuted with a measurement and two taken.

Blocking finding: refuted. cargo clippy -p rustynes-core --features ppu-state-trace does lint rustynes-ppu. The general rule you cite — clippy lints only the selected package and typechecks its dependencies — does not hold for a workspace member: cargo runs clippy-driver through RUSTC_WORKSPACE_WRAPPER, which covers every workspace member in the graph, and -D warnings reaches them.

Measured rather than argued. Reverting one const fn fix in rustynes-ppu/src/state_trace.rs makes that exact step fail:

error: this could be a `const fn`
   --> crates/rustynes-ppu/src/state_trace.rs:590:5
error: could not compile `rustynes-ppu` (lib) due to 1 previous error

Restoring it returns zero errors. That is a mutation with its control.

-p rustynes-core is also the broader target — it lints the core's own feature-gated code as well as the PPU's — so narrowing to -p rustynes-ppu would lose coverage. The workflow now carries that evidence inline, because the finding was reasonable and someone will otherwise reach the same conclusion again.

Round-1 suggestion (dropped > 0 should fail): taken. You are right, and this project has already been bitten by exactly it — a warning that fired on every correct injection run and was never seen, because the caller redirected stderr. write_fetch_trace now writes the file first (its records are real and a partial capture is worth inspecting) and then exits 1. Verified both ways: --fetch-trace 100 on ppufetch drops 6,958 reads and exits 1; --fetch-trace 200000 exits 0.

Round-1 nitpick (from_bytes hand-indexing): taken, as buf[0..4].try_into().ok()? — the slice lengths now come from the field widths, so a field that moves cannot silently read its neighbour, and it propagates rather than panicking.

…gaps

An exhaustive pass over the documents this release touches. Five findings, and
the first is the only one with a functional consequence.

.GITIGNORE DID NOT COVER THREE OF NINE GOLDEN ARTIFACTS

The block's own comment says none of the exporter's output belongs in the tree.
Three suffixes were not matched, found by testing each one against
`git check-ignore` rather than by reading the list:

- `*.ram.bin` does NOT match `<stem>.ram_init.bin` -- the suffixes differ -- so
  the seeded work RAM that every co-simulation gate now requires was never
  ignored.
- `irq.csv` has been uncovered since v2.4.2.
- `fetch.bin` arrived with v2.5.4.

Each pattern was verified to match nothing tracked before being added, which is
the check the block's own note about bare patterns matching at any depth asks for.

BOTH CHANGELOG HEADERS ADVERTISED A COVERAGE NEITHER HAS

`CHANGELOG.md` said the full per-version detail lives in `CHANGELOG-FULL.md`.
`CHANGELOG-FULL.md` ends at `[2.0.4]` (2026-07-08) and has not been extended
through the thirty-odd releases since. Both files now state the boundary and
name where that depth actually lives from v2.0.5 onward: `.github/release-notes/`
(v1.10.0 onward, with gaps at v2.2.6-v2.2.9, v2.3.8 and v2.4.0), the published
GitHub Releases, and VERSION-PLAN.md's per-release rows.

It is deliberately NOT backfilled. That file's value is detail written while the
work was fresh; reconstructing thirty releases of it from summaries would produce
confident prose nobody measured, which is the failure mode this project spends
most of its review effort on.

README

Three corrections. `nes_golden_export` emits NINE artifacts, not the five claimed
since v2.4.1 -- and the count is now given with a pointer to the crate's module
docs rather than as a bare number, because the set grows with each rung. The
trace-lint gap is described as closed rather than open, since v2.5.4 closed it.
And the "Fabric" line is marked delivered, with the current v2.5.1 -> v2.7.0 line
named, so a reader landing there is not told about a programme that finished.

DOCS/TESTING-STRATEGY.MD GAINED THE LAYER IT NEVER DESCRIBED

The file enumerates six testing layers and had no mention of co-simulation at
all, though the project has been building a verification ladder against an
independent implementation since v2.4.1. Layer 4.7 now covers the oracle
inversion, why `rustynes-cosim` is excluded from the workspace, the
gate-versus-diagnostic partition, the mutation requirement with its three
outcomes, the observation that a gate only tests at the resolution it samples,
and -- said plainly -- that these gates do NOT run in CI.

THE MISTER TO-DOS WERE THREE SPRINTS STALE

`TASKS.md` still had v2.5.1's injection API unchecked though it shipped, and
v2.5.3/v2.5.4 open; the pre-rung partition doc was listed as unwritten when it
exists under a different name, recorded rather than silently reworded.
`IMPLEMENTATION_PLAN.md` said the PPU was "Not started" and quoted the superseded
27,388-cycle nestest bound. `SPRINT_PLAN.md` gained a status column.

One correction is more than bookkeeping: the standing rule "a rung may not start
until the one below is green IN CI" is not currently achievable, because none of
the DUT gates run in CI -- they need the oracle's goldens and a build of the
excluded crate, neither of which exists in the sibling repository's workflows.
The rule now says what it can mean today, because a rule nobody can satisfy is a
rule that quietly stops being applied.
…s first tests

Two review findings from #450's second Antigravity round, both taken. No blocking
issues were raised.

AN UNBOUNDED CAPACITY FROM A COMMAND-LINE ARGUMENT

`FetchTrace::with_capacity` allocated `capacity.min(1 << 20)` but STORED the raw
`capacity`, so the clamp applied only to the initial allocation. Pushing past a
million records then reallocated the buffer without bound:
`--fetch-trace 1000000000` would have grown its way to roughly twelve gigabytes.

Fixed in two places, deliberately:

- `MAX_CAPACITY` (1,048,576 records, 12 MiB) now clamps the STORED capacity, so
  the invariant holds whatever a caller passes -- including callers that never go
  through the CLI. `capacity()` exposes the clamped value, which is what makes it
  safe to rely on: a caller that asked for more can see that it did not get it.
- `nes_golden_export` REFUSES an argument above the cap, before a cycle is
  simulated. Validating at the boundary is the project's own rule, and there is a
  specific reason not to lean on the silent clamp: a clamp the caller never
  learns about produces a golden covering less than the run it claims to, and the
  drop counter exists precisely so that such a golden cannot pass unnoticed.
  A window needing more than a million reads wants to be shorter, not buffered
  larger -- three frames of a rendering ROM is under ten thousand.

Verified in all three directions: `--fetch-trace 99999999` exits 2 with the
message, `--fetch-trace 0` exits 2, and `--fetch-trace 200000` exits 0.

THE WIRE FORMAT HAD NO TESTS

`fetch_trace.rs` shipped with zero. That matters more than the line count
suggests: this is a wire format a Verilator testbench in another repository
writes and `fetch_diff.py` reads, so a field silently changing width or order
would surface as a co-simulation divergence several steps from its cause.

Five tests now. The byte layout is asserted against hand-written expected bytes
rather than against `to_bytes`'s own output -- a test that builds its expectation
with the function under test agrees with itself forever, which is the failure
mode AGENTS.md records from v2.4.0. The round trip includes scanline -1, the
pre-render line and the only negative this field carries, so wrong signedness
cannot pass. A short buffer must decode to `None` at every length from 0 to
RECORD_SIZE-1 rather than panicking. And the overflow test asserts the records
KEPT are the FIRST ones: a ring buffer here would silently answer a question
about the end of a run with data from its middle.

Demonstrated to fail, three mutations, all CAUGHT: byte order (`to_le_bytes` ->
`to_be_bytes` on the scanline), the capacity clamp removed, and a ring-buffer
overwrite on push past capacity.

The third comment, on `u32::try_from(self.frame).unwrap_or(u32::MAX)`, is left
as it is -- the same reviewer's other round called it "a great touch", and
saturating is the intent.

Re-verified after the change, because `rustynes-ppu` is a chip crate:
AccuracyCoin (RAM) 141/141 100.00%, nestest 0-diff, the full workspace suite,
all four trace-feature clippy steps, the excluded crate's own fmt/clippy, the
thumbv7em no_std build, and the five release audits.
@doublegate

Copy link
Copy Markdown
Owner Author

Round 2 — both suggestions taken, no blocking issues raised. Thanks.

with_capacity clamping: correct, and worse than it looked. The clamp applied only to the initial allocation while self.capacity kept the raw value, so pushing past a million records reallocated without bound — --fetch-trace 1000000000 would have grown to roughly twelve gigabytes.

Fixed in two places on purpose:

  • MAX_CAPACITY (1,048,576 records, 12 MiB) now clamps the stored capacity, so the invariant holds for callers that never go through the CLI. capacity() exposes the clamped value, which is what makes it safe to rely on.
  • nes_golden_export refuses an argument above the cap rather than leaning on the silent clamp. A clamp the caller never learns about produces a golden covering less than the run it claims to, and the drop counter exists precisely so that cannot pass unnoticed.

Verified three ways: --fetch-trace 99999999 exits 2 with the message, 0 exits 2, 200000 exits 0.

Round-trip tests: added, five of them. You were right that this needed them — it is a wire format another repository's Verilator testbench writes and fetch_diff.py reads, so a field changing width or order would surface as a co-simulation divergence several steps from its cause.

The byte layout is asserted against hand-written expected bytes rather than against to_bytes's own output, because a test that builds its expectation with the function under test agrees with itself forever. The round trip includes scanline -1 (the pre-render line, and the only negative this field carries). A short buffer must decode to None at every length from 0 to RECORD_SIZE-1. And the overflow test asserts the records kept are the first ones — a ring buffer here would answer a question about the end of a run with data from its middle.

Demonstrated to fail: three mutations, all CAUGHT (byte order, clamp removed, ring-buffer overwrite).

The unwrap_or(u32::MAX) nitpick is left as it is — saturating is the intent, and your other round called it out as such.

rustynes-ppu is a chip crate, so the accuracy battery was re-run rather than assumed: AccuracyCoin (RAM) 141/141, 100.00% and nestest 0-diff.

@doublegate
doublegate requested a lite review from Copilot August 23, 2026 18:55
@doublegate

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

Closes a one-record coverage hole in five of eleven rung-1 gate invocations, and
corrects the diagnosis that had deferred it.

WHAT THE COMPARATOR NOW REQUIRES

`cpu_boot_trace_diff` treated ANY length difference as a divergence. The oracle's
window is set by `--boot-trace` and the DUT's by `--cycles`, so the two rarely
land on the same instruction boundary, and five ROMs printed "All N aligned
records match" followed by a bare "Length mismatch".

The new rule is coverage, not equality:

- A SHORT actual FAILS, and says why: part of the reference window was never
  compared, which is a coverage hole.
- A LONGER actual PASSES, and the surplus is ANNOUNCED -- those records were
  checked against nothing, and a narrowing nobody is told about reads as full
  coverage.
- Equal lengths say nothing, as before.

THE FIRST DIAGNOSIS WAS WRONG, AND IT WAS MINE

One commit ago I wrote, in both the sibling repo's Makefile and its
`docs/rung1-6502.md`, that this was "a window boundary, not a divergence" and
left it strict rather than "loosening a rung-1 gate mid-release".

The gate was not passing. Under the coverage rule, `opgroup3`, `opgroup5`,
`opgroup6`, `ppuregs` and `ppuscroll` were genuinely SHORT -- one reference
record each, never compared. Five extra cycles on each window closes it, and the
SAME five works for every one: a single systematic boundary rather than per-ROM
tuning, and the minimum was searched for rather than guessed. All eleven now exit
0, five reporting "Actual trace runs 2 record(s) past the reference".

Demonstrated in both directions: `ppuregs` at its old 32,001-cycle window still
fails with "Actual trace is SHORT", and a palette-mirroring mutation still fails
as a divergence. Control passes.

The half of the earlier reasoning that was right is kept, because it is what this
release is about: an exit code permanently non-zero on a gate you have decided is
fine is an exit code people stop reading -- and v2.5.4 proves it, since a
pass/fail classifier grepped this tool's prose and matched "Length mismatch" on
the substring "match", reporting five failures as passes. But the fix for that is
to make the exit code MEAN something, not to make the message quieter. A gate
that fails for a reason you have decided is uninteresting is a gate you will
route around, and the route-around is where the real error enters.

A FIFTH UNLINTED SURFACE

The four trace-feature clippy steps added earlier in this release lint
`rustynes-core`. `cpu_boot_trace_diff`, `ppu_trace_diff` and `trace_dma_4015`
live behind the SAME feature names on `rustynes-test-harness`, which no
invocation reached -- so they had never been linted either, and all three held a
collapsible-`if` finding. These are the binaries every MiSTer rung comparison
runs. A fifth CI step covers them, and it earned its place on its first run, the
same way the core steps did.

The corrected diagnosis is written into the release notes and CHANGELOG in place
rather than replacing the deferral silently: the record that the claim was made
is part of what makes the correction worth anything.
Two blocking findings from #450's third Antigravity round. The first is refuted
by measurement and pinned; the second is a real property, scoped and documented.

REFUTED: the fast dot path does not bypass the fetch trace

The finding said `tick_visible_render_fast` is disabled under `ppu-state-trace`
but not `ppu-fetch-trace`, so a build with only the fetch trace would run the
fast path and "bypass per-dot `read_vram` calls, silently dropping background
fetches".

It does not, and the reason is where the hook was put. `read_vram` is the single
choke point every VRAM read passes through, and the fast path reaches it by the
same route the general path does: `fetch_nt`, `fetch_at`, `fetch_bg_lo` and
`fetch_bg_hi` all call it. `ppu-state-trace` disables the fast path for a
different reason -- its hook fires per DOT, and the fast path exists precisely to
skip per-dot bookkeeping.

That is an argument, and an argument is not evidence. The test is the evidence,
and it is a test rather than a reply so the property cannot regress silently: run
ppufetch with the fast path on and off, require byte-identical traces. 84,712
bytes both ways.

AND THE TEST ALMOST PROVED NOTHING

The first version passed, and two mutations dropping a fetch came back NOT
CAUGHT. Instrumenting showed why they were invisible -- Python's `str.replace`
edits ALL occurrences, so the mutation removed the fetch from both paths and the
two traces stayed equal while both were wrong. A mutation confined to the fast
function's byte range is CAUGHT, twice.

But that investigation raised the real question: was the fast path being taken at
all? It was -- 11,755 dots with the flag on, 0 with it off -- and a test that
could not tell the difference is the failure this project keeps finding, where a
check agrees about something it never reached. So `fast_path_hits` is now a
counter behind `ppu-fetch-trace`, and the test asserts it BEFORE comparing:
`fast_hits > 1000` and `slow_hits == 0`. Without those two lines the test passes
when the fast path never runs, comparing the general path against itself.

`snapshot_schema_audit` caught the new field immediately and required its reason
in writing, which is the audit doing exactly what it is for.

SCOPED, NOT FIXED: writes are absent from the trace, on purpose

The second finding is correct that a real 2C02 drives its address bus on writes
too, so a trace of reads alone is not a complete picture of the bus. It is a
scoped decision rather than an oversight, and the module now says so.

Adding writes TODAY would break the gate for a reason unrelated to any RTL. The
DUT captures a dot only while its background fetch pipeline drives the bus; it
has no write path to capture, because `$2007`-during-rendering is v2.5.8's
subject. Recording writes here would put records in the reference that the DUT
cannot produce, and the comparator would report a divergence caused entirely by
the oracle having been made more complete than its counterpart.

So the rule is the one applied to every narrowing in this release: the scope is
stated rather than assumed. This is a trace of VRAM READS; the comparator narrows
further to the background-fetch dots and prints what it excluded on every run.
Writes join it when the DUT can drive them.

The two suggestions are not taken, and why: extending the dead-code `cfg_attr` to
`ppu-fetch-trace` would be wrong, since that feature does NOT disable the fast
path -- the attribute is correct as written. And `from_bytes` returning `Option`
is right for a pure length check with one failure mode; a typed error would name
the same thing twice.

Verified: AccuracyCoin (RAM) 141/141, nestest 0-diff, the full workspace suite,
the excluded crate's own fmt/clippy/test, and all four trace clippy steps.
@doublegate

Copy link
Copy Markdown
Owner Author

Round 3 — first blocking finding refuted with a measurement and pinned by a test; second correct in principle, scoped and now documented.

1. The fast path does not bypass the trace

read_vram is the single choke point every VRAM read passes through, and the fast path reaches it by the same route the general path does — fetch_nt, fetch_at, fetch_bg_lo and fetch_bg_hi all call it. ppu-state-trace disables the fast path for a different reason: its hook fires per dot, and the fast path exists precisely to skip per-dot bookkeeping.

That is an argument, and an argument is not evidence. crates/rustynes-cosim/tests/fast_path_does_not_bypass_the_fetch_trace.rs is the evidence — same ROM with the fast path on and off, byte-identical traces, 84,712 bytes both ways — and it is a test rather than a reply so the property cannot regress silently.

Worth reporting: the first version of that test almost proved nothing. It passed, and two mutations dropping a fetch came back NOT CAUGHT. Python's str.replace edits all occurrences, so the mutation removed the fetch from both paths and the two traces stayed equal while both were wrong. Confined to the fast function's byte range, the mutations are CAUGHT.

That investigation raised the better question — was the fast path being taken at all? It was: 11,755 dots with the flag on, 0 with it off. So the test now asserts that before comparing (fast_hits > 1000, slow_hits == 0), via a fast_path_hits counter behind ppu-fetch-trace. Without those two lines it would pass when the fast path never runs, comparing the general path against itself. snapshot_schema_audit caught the new field immediately and required its reason in writing.

2. Writes are absent on purpose, and the module now says so

You are right that a real 2C02 drives its address bus on writes too, so a trace of reads alone is not a complete picture of the bus.

Adding writes today would break the gate, for a reason unrelated to any RTL. The DUT captures a dot only while its background fetch pipeline drives the bus; it has no write path to capture, because $2007-during-rendering is v2.5.8's subject. Recording writes here would put records in the reference that the DUT cannot produce, and the comparator would report a divergence caused entirely by the oracle having been made more complete than its counterpart.

So the scope is stated rather than assumed: this is a trace of VRAM reads, the comparator narrows further to background-fetch dots and prints what it excluded on every run, and writes join when the DUT can drive them.

The two suggestions, not taken

Extending the dead-code cfg_attr to ppu-fetch-trace would be wrong — that feature does not disable the fast path (see §1), so the attribute is correct as written and widening it would suppress a real finding. And from_bytes returning Option fits a pure length check with one failure mode; a typed error would name the same thing twice.

Verified after the change: AccuracyCoin (RAM) 141/141, nestest 0-diff, the full workspace suite, the excluded crate's own fmt/clippy/test, and all four trace clippy steps.

The test added one commit ago read `ppufetch.nes` from the SIBLING FPGA
repository. That path exists on my machine and nowhere else, so it passed
locally and failed every CI job with "No such file or directory (os error 2)".

AGENTS.md already records this exact failure -- "my audit required a gitignored
corpus, so it passed locally and failed every CI job" -- which makes this the
second time, and the reason it is worth a commit message rather than a quiet
amend. A test whose fixture lives outside the repository under test is a test
that has not been run anywhere but one laptop.

It now uses `tests/roms/nestest/nestest.nes`, committed here under CC0. That
removes the cross-repository dependency entirely rather than skipping when the
file is absent -- a graceful skip would have turned a hard CI failure into a
silent no-op, which is worse.

nestest is also the better exerciser. Measured across candidates: several
AccuracyCoin sub-tests never enable rendering at all and drive the fast path ZERO
times, while nestest reaches 124,165 hits.

AND THE EXERCISER ASSERTION EARNED ITS PLACE IMMEDIATELY

Switching ROMs, the first attempt used four frames and the test FAILED with "the
fast path ran only 1282 times -- this test would then be comparing the general
path against itself and proving nothing". nestest does not render straight away.
That assertion was added in the previous commit precisely so a change like this
could not quietly produce a vacuous pass, and it caught one within the hour.

Eight frames now, and the number is measured rather than picked: four is too few,
six reaches 124,165, eight leaves margin without approaching the trace capacity.

Mutations re-run against the new ROM, both confined to the fast function's byte
range so they cannot alter the general path too: dropping the nametable fetch and
dropping the pattern-high fetch are both CAUGHT, and the control passes.
`AccuracyCoin` needed backticks under `clippy::doc_markdown`, and the frame
count in the same sentence was stale after the previous commit moved it.

Worth recording WHY this reached a commit at all: the pre-commit hook runs the
WORKSPACE clippy, and `rustynes-cosim` is excluded from the workspace. So a
finding in this crate passes every local gate and fails only in the dedicated CI
step that exists for exactly this reason. Run `cargo clippy` inside the crate
directory before pushing anything that touches it -- the same asymmetry that made
the crate worth excluding makes it invisible to the default checks.
@doublegate
doublegate merged commit c8f5cdf into main Aug 23, 2026
29 checks passed
@doublegate
doublegate deleted the chore/release-v2.5.4-escapement branch August 23, 2026 20:31
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