chore(release): cut v2.5.4 "Escapement" — the background fetch pipeline, and an access two dots early that five gates could not see - #450
Conversation
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.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: ✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
@coderabbitai 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.
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 issuesNone found. Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-23 20:02 UTCAntigravity review (Gemini via Ultra)Error: timeout waiting for response Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-23 19:57 UTCAntigravity review (Gemini via Ultra)This PR cuts the v2.5.4 release, introducing the Blocking issues
Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-23 19:51 UTCAntigravity review (Gemini via Ultra)This PR cuts the v2.5.4 release, adding a per-dot PPU bus-address capture ( Blocking issues
Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-23 19:23 UTCAntigravity review (Gemini via Ultra)Error: timeout waiting for response Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-23 19:00 UTCAntigravity 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 issuesNone found. Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-23 18:30 UTCAntigravity review (Gemini via Ultra)This PR cuts the v2.5.4 release, introduces a new feature-gated Blocking issuesNone found. Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-23 18:22 UTCAntigravity review (Gemini via Ultra)This PR cuts the v2.5.4 release, adds a Blocking issues
Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-23 18:12 UTCAntigravity review (Gemini via Ultra)This PR implements a new Blocking issuesNone found. Suggestions
Nitpicks
Automated first-pass review by |
…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.
|
Thanks — three findings, one refuted with a measurement and two taken. Blocking finding: refuted. Measured rather than argued. Reverting one Restoring it returns zero errors. That is a mutation with its control.
Round-1 suggestion ( Round-1 nitpick ( |
…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.
|
Round 2 — both suggestions taken, no blocking issues raised. Thanks.
Fixed in two places on purpose:
Verified three ways: 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 The byte layout is asserted against hand-written expected bytes rather than against Demonstrated to fail: three mutations, all CAUGHT (byte order, clamp removed, ring-buffer overwrite). The
|
|
@coderabbitai 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.
|
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
That is an argument, and an argument is not evidence. 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 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 ( 2. Writes are absent on purpose, and the module now says soYou 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 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 takenExtending the dead-code 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.
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.svnow 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 underdocs/rung3-ppu.md's partition — unlike the latches behind it, which are this project's decomposition of what was fetched.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.cppsplits 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$2001took 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
$2002there, and through v2.5.3 the testbench answered from flat memory while the oracle returned$80with vblank set. With the register file answering, the DUT matches onpc,bus_addr,bus_dataandbus_accessfor 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 ROMrendered from
$2000throughout.ctrl[4] → 1'b0on the pattern-table select — the ROM wrotePPUCTRL = 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
BRKpassing 186/186 while the interrupt return address was wrong.ppufetch.nesgained a second rendering window at$2800with background patterns at$1000.PPUCTRLis written before the$2006pair on purpose:PPUCTRLbits 0–1 writet[11:10]and the$2006high byte then overwrites all oft, so setting the nametable throughPPUCTRLalone 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-traceor the newppu-fetch-trace.--workspace --all-targetsreaches each crate's default feature set only, and therustynes-cosimclippy step lints that package while compilingrustynes-coreandrustynes-ppuas 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-tracehad six-D warningserrors 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 orphanstick_visible_render_fastunder that feature and only that feature — live by default, dead under one flag, which is the one shape that earns a scopedallowrather than a deletion.Also fixed:
nes_golden_export's usage text, which documented neither--fetch-tracenor any of the four injection flags added at v2.5.1.Verified, not asserted
rustynes-ppuchanged, so the accuracy battery was run rather than claimed: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.