feat(v2.4.0): "Concordance" — atomic writes, the timeline counter, and the owed upstream sync - #428
Conversation
…rsists user data
v2.4.0 item C. Extracts the seven-property write sequence from `Config::save_to`
into `crate::atomic_write` and adopts it at every remaining call site.
WHY A MODULE AND NOT THREE COPIES
v2.3.9 made the config path atomic after `fs::write` was found capable of leaving
a user holding a truncated `config.toml`. It took seven properties to get right,
and FIVE of them came from review rather than from the first draft. A property
that five separate reviews had to find once will not be independently
rediscovered three more times, which is the entire argument for one
implementation.
WHAT EACH PATH WAS ACTUALLY DOING
config.rs::save_to .......... the full v2.3.9 sequence ....... 7 of 7
save_state.rs::save_to_slot . fs::write .................... 0 of 7
cheats.rs::save_for_rom ..... fs::write .................... 0 of 7
per_game.rs::save_overlay ... tmp + rename ................. 2 of 7
`save_state.rs` is the one that matters most and the plan named it last: A
TRUNCATED SAVE STATE IS A USER'S GAME PROGRESS, a worse loss than a truncated
config, and it was still using the bare call the config path had already been
fixed for. It is also the path most likely to be written under load -- rewind
capture, run-ahead and netplay rollback all produce save states, and a user
pressing F1 during a busy frame is the ordinary case rather than an edge one.
`per_game.rs` was NOT IN THE PLAN and is the instructive one. It writes a sibling
temp file and renames, so it *looks* correct and a sweep for `fs::write`
straight onto a target clears it. It held two of seven. The two that mattered:
no `fsync`, so the rename could commit a directory entry pointing at bytes that
never reached the medium; and a FIXED scratch name,
`path.with_extension("json.tmp")`, shared across every process and every
concurrent call -- the exact failure the mechanism exists to prevent,
reintroduced by the mechanism itself. A partially-correct implementation is
harder to spot than an absent one, which is an argument for the shared helper
that the plan did not have when it was written.
THE WINDOWS TAIL, WHICH THE CONFIG PATH NEVER HAD
`std::fs::rename` maps to `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING`, so
replace-existing holds on both platforms -- with a caveat POSIX does not have: on
Windows the rename FAILS if another process has the target open, and an antivirus
scanner or a search indexer reading `config.toml` is enough. That is why the
config path never needed it, and why it would have gone unnoticed until a Windows
user reported a save that failed for no visible reason. A bounded retry now
covers it, and when the attempts are exhausted the error PROPAGATES: a save that
fails silently after N attempts is worse than one that fails on the first,
because the user gets no signal at all.
THE RETRY LOOP IS PORTABLE SO THAT IT CAN BE TESTED
The obvious shape is a `#[cfg(windows)]` block. That is deliberately not used for
the loop, because CI runs the suite on Linux and a cfg-walled retry is code no
test on the primary platform can execute -- an untested mechanism guarding a
failure nobody can reproduce locally. Instead the loop is portable and the
PREDICATE is platform-scoped, and the loop takes both the operation and the
predicate as parameters.
That second parameter is not tidiness; it was forced by a mutation. The first
version called `is_transient_rename_error` directly, which reads as testable and
is not: that predicate is unconditionally false on Unix, so the exhaustion branch
is UNREACHABLE on the platform CI runs. A mutation making exhaustion return
`Ok(())` -- silently reporting a save that never happened, the worst outcome this
module has -- was NOT CAUGHT by the test written for it. Injecting the predicate
makes the branch reachable everywhere, and a separate test pins the Unix
single-attempt guarantee with the real predicate.
MUTATION RESULTS, INCLUDING THE TWO THAT ARE NOT COVERED
Seven properties deleted in turn. Five caught:
symlink resolution removed ............... CAUGHT
broken-symlink fallback removed .......... CAUGHT
exact mode after creation removed ........ CAUGHT (needed a new test, below)
occupied-scratch retry removed ........... CAUGHT
exhaustion reports success ............... CAUGHT (needed the predicate param)
unix predicate forced true ............... CAUGHT
Two are NOT observable from inside the process, and the module says so rather
than leaving a green suite to imply coverage it does not have:
* `fsync` before the rename. Deleting it changes nothing an in-process
assertion can see -- the page cache serves the read back identically. Only a
power loss or a fault injector distinguishes them.
* Mode applied AT CREATION. Deleting `opts.mode(...)` still ends at the right
mode, because the explicit `set_permissions` after it corrects the result.
Creation-mode is a RACE-WINDOW NARROWING, not an end-state property: it
removes an interval in which the file sits at the umask default, and a test
can only observe the end state.
Neither should be removed on the evidence that no test fails. An untested
property is not an unnecessary one.
The mode test itself was rewritten because the mutation pass caught it asserting
less than its name claimed: `opts.mode(0o600)` at creation already yields 0600
under any ordinary umask, so deleting the exact set afterwards left it green.
`open(2)` applies `mode & ~umask`, so only a mode carrying bits the umask clears
(0666 under the usual 022 gives 0644) distinguishes the two mechanisms. The new
test OBSERVES the umask rather than assuming 022, and returns early when the
umask masks nothing, because the two are then genuinely indistinguishable.
THE WASM32 GATE CAUGHT WHAT NATIVE CLIPPY DID NOT
`sync_parent_dir` compiles to an empty body off Unix, which trips clippy's
`missing_const_for_fn` -- visible only on a non-Unix target, so native clippy
passed while the wasm32 gate failed. Split into two cfg'd definitions with the
non-Unix one `const`, which also states the truth: on Windows `MoveFileEx`
already orders the metadata write, and on wasm there is no directory to sync.
Recorded alongside it: the retry `sleep` is unreachable off Windows, which
matters on wasm specifically because `std::thread::sleep` cannot block on
`wasm32-unknown-unknown`.
NET
`config.rs` loses 273 lines, of which the great majority is the rationale that
now lives once in the module rather than being duplicated at four call sites.
Behaviour on Unix is unchanged; the config path GAINS the Windows retry it never
had.
GATES
cargo fmt --all --check ......................... clean
clippy: default / scripting / scripting,hd-pack /
retroachievements / full ................ clean
clippy wasm32: default / wasm-canvas ............ clean
RUSTDOCFLAGS=-D warnings cargo doc .............. clean
rustynes-frontend lib tests ..................... 557 passed
atomic_write module tests ....................... 12 passed
Frontend-only: no emulation source changes, so the AccuracyCoin 141/141 and
nestest 0-diff results verified for v2.3.9 are unaffected.
…ntouched config v2.4.0 item D. `graphics.hd_packs` (v1.5.0) and `graphics.shader_presets` (v1.2.0) both documented a pre-feature config as "byte-identical". Both were byte-identical only until the first save. `#[serde(default)]` is a LOAD guarantee. It says nothing about what SAVE writes, and the TOML serializer emits an empty table for an empty collection -- so a user who had never opened the HD-pack manager or saved a shader preset found their config rewritten with a bare `[graphics.hd_packs]` and `[graphics.shader_presets]` on the first save after upgrading. Not data loss, but a claim the file itself contradicted, and one that made a genuine diff harder to read. v2.3.9 corrected the PROSE and deliberately left the behaviour, on the reasoning that changing what two shipped features write is a separate decision from fixing a false claim. This is that decision, and the plan put it here for that reason. `hd_packs` is a bare `BTreeMap`, so `skip_serializing_if` names `BTreeMap::is_empty` directly, matching `input.latency_reports` which got this treatment in v2.3.9. `shader_presets` is a `ShaderPresetBank` struct wrapping a map, so it needed an `is_empty` on the type before the attribute had anything to name -- which is a fair part of why it was the one left behind when `latency_reports` was fixed. BOTH DIRECTIONS, BECAUSE ONE DIRECTION PROVES NOTHING The plan is explicit that a one-directional test passes just as happily against a field that never persists anything at all. So each field gets: the empty case is OMITTED, a populated one SURVIVES, and both round-trip back through `from_str` -- because the string checks verify the KEY and say nothing about the VALUE. Mutation-tested in both directions, each confirmed to have actually run its named test rather than matching zero and exiting 0: removed hd_packs skip ................... CAUGHT removed hd_packs skip (property test) ... CAUGHT removed shader_presets skip ............. CAUGHT over-eager is_empty (always true) ....... CAUGHT That last one is the direction that matters most: an `is_empty` returning `true` unconditionally would silently DISCARD a user's saved presets on every save, which is a data-loss bug wearing the shape of a tidiness fix. It is caught. A THIRD TEST, FOR THE FIELD THAT DOES NOT EXIST YET `a_default_config_writes_no_empty_opt_in_tables` asserts the property once rather than per field: a default config must carry no empty table for any of the three opt-in collections. The per-field tests would each still pass if a FOURTH such field were added tomorrow without the attribute; this is the one that would start failing. The defect being fixed here is precisely "a field was added and the save-side property was not considered", so the regression net should be shaped around the field that has not been written yet. GATES cargo fmt --all --check ......................... clean clippy: default / full .......................... clean clippy wasm32: default / wasm-canvas ............ clean RUSTDOCFLAGS=-D warnings cargo doc .............. clean rustynes-frontend lib tests ..................... 560 passed Frontend-only. Config files written by an older build still load unchanged -- this only removes keys that carried no information.
…s that read it
v2.4.0 item B. `Nes` gains a session-local `timeline_generation` that changes
whenever the emulator jumps to a different point on its timeline, and the debug
telemetry that describes a run now clears itself when it does.
WHY A COUNTER RATHER THAN MORE CALL SITES
v2.3.9 item E cleared the call stack and access counters on a ROM change and
recorded, honestly, that the same telemetry is NOT cleared on a save-state load.
A two-call-site patch was declined as insufficient, with the reason measured:
native load-state ....... reachable from a frontend call site
wasm load-state ......... NOT -- restores inside a `spawn_local` task holding
only a cloned `EmuHandle`
rewind .................. NOT -- happens entirely inside the core
netplay rollback ........ NOT
One of four, and patching it would have presented a quarter of the fix as the
whole of it.
The plan originally proposed each consumer remembering the last `Nes::cycle()` it
saw and noticing a non-monotonic step. Review on #415 proposed better, and the
difference is a case the heuristic provably cannot cover: a restore to a LATER
state advances `cycle()`, so it is indistinguishable from execution. A core-side
counter sees it, and needs no cooperation from any call site.
LOUD VERSUS QUIET, WHICH THE CODEBASE ALREADY DISTINGUISHED
`restore_inner` already takes `clear_rewind`, which is exactly the distinction:
`true` for a user-driven load that invalidates rewind history, `false` for a
same-timeline machine-driven restore (run-ahead's per-frame rollback, netplay's
rollback-resimulate) where the history stays valid. The counter reuses it rather
than inventing a parallel notion.
This DEPARTS FROM THE PLAN'S ENUMERATION, deliberately. The plan listed netplay
rollback as a bump site, and also stated the mechanism -- "a same-timeline restore
is exactly one that must NOT bump the counter". The two cannot both hold; netplay
rollback goes through `restore_quiet` precisely because it is same-timeline. The
mechanism wins: bumping there would clear a user's telemetry sixty times a second
under run-ahead, which is a worse defect than the stale telemetry this fixes.
Both directions are pinned by tests.
`reset` and `power_cycle` bump too. They are discontinuities by any reading, and
a reconstructed call stack describes a run that no longer exists after either.
The bump happens BEFORE the restore can fail. A partially-applied restore is a
discontinuity whether or not it completed, and a consumer that keeps stale
telemetry because the jump errored is the bug in its most confusing form.
THE COUNTER MUST NOT BE SERIALIZED
Its only job is to be DIFFERENT after a discontinuity. Serializing it would put an
OLD value back on restore, so loading a state saved earlier in the same session
could hand a consumer a generation it has already seen -- and the consumer would
conclude nothing jumped at the exact moment something did.
The plan asked for an entry in `snapshot_schema_audit.rs`. That file audits `Ppu`,
`Cpu`, `Apu` and `Opll`; `Nes` is not among them, and retrofitting it means
classifying every field of `Nes`, which is a larger change than this item. So the
property is pinned by an EXECUTABLE assertion instead, which is stronger than a
list entry would have been: snapshot at generation N, advance past N, restore, and
assert the generation did not come back to N. Simulating serialization makes it
fail with exactly the diagnostic it should:
the generation went BACKWARDS to 1 (a consumer had already seen 3), so the
counter is being carried in the save state -- which defeats its only purpose
THE CONSUMER SIDE, LANDED WITH IT
Checked once per frame in `DebuggerOverlay::pump_watchpoints`, which already runs
under the emu lock with `&mut Nes` -- rather than at each site that could cause a
jump, since two of the four are not reachable from one.
The decision is extracted into a `TimelineWatch` value rather than an
`Option<u64>` field, for a reason that has now come up twice in this release:
`DebuggerOverlay::new` needs a window and a wgpu device, so anything living only
inside it cannot be unit-tested. The same argument produced the injectable
predicate in `atomic_write`.
The FIRST observation adopts rather than reporting a jump, because a fresh `Nes`
starts its counter at zero and "never observed" must stay distinguishable from
"observed a zero" -- otherwise loading a ROM and immediately loading a save state
compares 0 against 0 and misses it. `clear_rom_bound_analysis` calls `forget()`
for the same reason: a generation from the previous cartridge is not comparable
with the new core's.
Only telemetry RECONSTRUCTED FROM A RUN is cleared. Watch lists and breakpoints
are user-authored and survive, under the rule v2.3.9 settled for ROM transitions.
A timeline jump is a weaker event than a cartridge change, so it can only ever
clear a subset of what that hook does -- never more.
ACCURACY -- VERIFIED, NOT ASSERTED
`rustynes-core` changes, so the contract was re-run rather than reasoned about:
AccuracyCoin (RAM): pass rate = 100.00% over 141 assigned tests
nestest: test result: ok. 1 passed
(The framebuffer decoder also reports 100.00% over 120 cells; the RAM decoder is
the authoritative one.)
MUTATIONS -- six, each confirmed to have actually RUN its named test
loud restore stops bumping .............. CAUGHT
quiet restore ALSO bumps ................ CAUGHT
reset stops bumping ..................... CAUGHT
first observation reports a jump ........ CAUGHT
forget() does nothing ................... CAUGHT
counter behaves as if serialized ........ CAUGHT
GATES
cargo fmt --all --check ......................... clean
cargo clippy --workspace --all-targets -D warn .. clean
clippy wasm32: default / wasm-canvas ............ clean
RUSTDOCFLAGS=-D warnings cargo doc --workspace .. clean
no_std thumbv7em-none-eabihf .................... clean
rustynes-core lib ............................... 188 passed
rustynes-frontend lib ........................... 564 passed
…cription
v2.4.0 item A, local half. Both upstream surfaces were fetched read-only and
compared against this tree, and the exact change is written down so the human
step is a COPY rather than a re-derivation -- which is the same reasoning behind
`libretro_info_audit.rs`. The v2.3.5 incident happened precisely because a
re-derivation was asked of a human and not performed.
The result is smaller than the plan assumed, in one direction and larger in the
other.
libretro-super's `dist/info/rustynes_libretro.info` needs ONE LINE:
`display_version` v2.3.5 -> v2.3.9. Everything else is already in sync,
including `license = "GPLv3+"` (landed upstream 2026-08-16 via
libretro-super#2069) and the description's 174-mapper-family figure. Verified by
diffing the fetched upstream file against this repo's copy: two changed lines,
which is that field and its counterpart. So the specific failure v2.3.5 found --
`.info` advertising MIT/Apache-2.0 eleven days after the relicense -- is closed,
and what remains is ordinary four-release version drift.
libretro/docs' `docs/library/rustynes.md` is the one still wrong, and it is the
LICENCE again:
The RustyNES core is licensed under
- MIT OR Apache-2.0
RustyNES has been GPL-3.0-or-later since v2.2.9 (ADR 0036), as a derivative work
of GPL emulators. `libretro/docs#1180` is open against exactly this, was filed
at the time, and has not been actioned upstream. That page is what a user reads
BEFORE the `.info`, so of the two surfaces the stale one is the more visible.
Neither PR is opened here. Both are outward-facing changes to third-party
repositories this project does not own, so they are prepared and left for a
maintainer. It is also why the local audit deliberately cannot see upstream: a
test that could would be a test that silently disagreed with a repository nobody
here controls.
Documentation only; markdownlint passes.
…hat misled me v2.4.0 item A, closing the local half. Both surfaces are now proposed upstream. libretro-super#2074 is opened: one line, display_version v2.3.5 -> v2.3.9. Verified before pushing rather than after -- the branch's dist/info/rustynes_libretro.info is now BYTE-IDENTICAL to this repository's copy. That is precisely the property libretro_info_audit.rs exists to make possible: the sync is a copy, not a re-derivation performed by hand. libretro/docs#1180 needed nothing. It has been open since 2026-08-16, and it is a PULL REQUEST rather than an issue -- re-verified today as OPEN, MERGEABLE/CLEAN, +1/-1, with zero comments. Correct, still applicable, simply unreviewed. A second PR would have been a duplicate. The misreading that nearly produced that duplicate is recorded, because it is reusable: "gh api repos/OWNER/REPO/issues/1180" RETURNS THE PULL REQUEST, since GitHub's issues endpoint serves PRs too. An earlier pass here ran exactly that, saw "#1180 open -- Correct the RustyNES core license", and concluded the docs fix still needed filing. "gh pr view" is the query when the question is whether a change is already proposed; the issues endpoint cannot answer it. Also cross-referenced #2074 on #1180, so a reviewer picking up either one can see that the docs page is now the last surface still showing the pre-relicense terms. Documentation only; markdownlint passes. Note on this commit: its first version was written with `git commit -m` and lost three backtick-quoted commands to shell substitution -- zsh evaluated them, and `<owner>` was read as an input redirect. Amended from a file. Commit bodies in this project carry command examples routinely, so -m is the wrong tool for them.
….4.0 work
THE CADENCE RULE (maintainer decision, 2026-08-20)
Upstream PRs are opened only on MINOR or MAJOR releases -- a `vX.Y.0` where `X` or
`Y` changed. Patch releases do NOT trigger an upstream sync: the `.info`
`display_version` is allowed to lag through a `v2.4.1`..`v2.4.9` run and is
brought current at the next `vX.Y.0`. Next scheduled sync: **v2.5.0**.
Attached to the existing bullet rather than added beside it, because that bullet
already carries the one OVERRIDE and the two must be read together: a **licence
change syncs immediately**, regardless of version. That is what the v2.3.5
incident was about -- RetroArch advertised MIT/Apache-2.0 for eleven days after
the GPL relicense -- and it stays on the same footing as a release.
EIGHT OPERATING NOTES, ALL FROM THINGS THAT ACTUALLY HAPPENED
* `gh api repos/OWNER/REPO/issues/N` RETURNS PULL REQUESTS. This nearly opened a
duplicate upstream PR: a pass ran exactly that against libretro/docs#1180, saw
an "open issue", and concluded the docs licence fix still needed filing -- into
a plan, a commit body and a user-facing summary. #1180 is a PR, open since
2026-08-16, MERGEABLE/CLEAN. Use `gh pr view` for "is this already proposed".
* Never write a commit body with `git commit -m` here. zsh treats backticks as
command substitution and `<word>` as an input redirect; a message documenting
three `gh` invocations lost all three and emitted `no such file or directory:
owner`. This project's house style puts command examples in commit bodies
routinely, so `-m` is structurally wrong for them -- use `-F` and then grep the
result for each phrase that was supposed to survive.
* A test that reimplements its subject is testing itself. Found in a test
written FOR a review finding: it declared a local `strip` helper and asserted
against that, so deleting the production code came back NOT CAUGHT. Only the
mutation pass could see it. The fix -- extract the decision into a named item
both sides call -- was needed THREE times this release (the atomic-write
predicate, `TimelineWatch`, and this), and in all three the code READ as
testable beforehand.
* Never byte-slice in a panic or format path. `&text[at..at+24]` panics inside a
multi-byte character, and these docs are full of em-dashes -- so the audit
crashed while formatting its own diagnostic. A diagnostic that can crash the
diagnosis is worse than none.
* Verify a reviewer's claim before writing the fix, especially when their other
findings were right. A claim that `starts_with("[workspace.package]")` matches
sub-tables is false (the literal ends with `]`), and the fix plus a commit body
describing "the regression I introduced" were written before it was tested.
* The let-chains claim is false and has been raised SEVEN times. Stable in
edition 2024; identical construct on `main` since v2.3.5; compiled green at
five SHAs. Refute on the CURRENT SHA rather than by reference to earlier ones.
* The workspace cannot carry a SemVer pre-release version -- cargo rejects it
before any test runs, because a caret requirement does not match a
pre-release.
* `release_anchor_audit.rs` pins 15 anchors across 10 documents and fails
closed; rewording an anchor means updating `ANCHORS` in the same change.
Documentation only. markdownlint passes (one MD038 fixed: a code span may not
begin with a space).
Folds the four v2.4.0 items into the existing [Unreleased] sections rather than adding parallel ones -- #427 had already opened Added/Fixed/Changed there, and markdownlint's MD024 caught the duplicate headings before they landed. Covers: the shared atomic-write helper and the fourth call site the plan did not name; the timeline generation counter and why it deliberately does not bump on a same-timeline restore; the two skip_serializing_if fields and why both directions are tested; and the upstream sync, which turned out to be one line.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR delivers the v2.4.0 “Concordance” release items by (1) introducing a shared atomic/durable write helper and migrating user-data persistence paths to it, (2) adding a session-local “timeline generation” counter in rustynes-core plus frontend tracking to clear derived debug telemetry on timeline discontinuities, and (3) updating release/process docs (including the owed libretro upstream sync cadence and release notes).
Changes:
- Add
rustynes-frontend::atomic_writeand migrate persistence paths (config, save states, cheats, per-game overlays) to use atomic + durable writes. - Introduce
Nes::timeline_generationand frontendTimelineWatchto detect timeline jumps and reset reconstructed telemetry. - Update v2.4.0 release documentation/automation artifacts and upstream-sync guidance.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| CHANGELOG.md | Adds/updates the v2.4.0 release entry to match the shipped changes. |
| crates/rustynes-core/src/nes.rs | Adds timeline_generation and bumps it on timeline discontinuities; includes pinning tests. |
| crates/rustynes-frontend/src/atomic_write.rs | Implements the shared atomic + durable write helper used by persistence paths. |
| crates/rustynes-frontend/src/lib.rs | Exposes the new atomic_write module from the frontend crate. |
| crates/rustynes-frontend/src/config.rs | Migrates config persistence to the shared atomic/durable write helper. |
| crates/rustynes-frontend/src/save_state.rs | Migrates save-state persistence to the shared atomic/durable write helper. |
| crates/rustynes-frontend/src/cheats.rs | Migrates cheat persistence to the shared atomic/durable write helper. |
| crates/rustynes-frontend/src/per_game.rs | Migrates per-game overlay persistence to the shared atomic/durable write helper. |
| crates/rustynes-frontend/src/shader_pass.rs | Adjusts shader preset persistence/byte-identity behavior per item D. |
| crates/rustynes-frontend/src/debugger/mod.rs | Adds TimelineWatch and clears derived telemetry when timeline_generation changes. |
| crates/rustynes-test-harness/tests/release_anchor_audit.rs | Updates release-anchor checks to account for v2.4.0 doc/version changes. |
| crates/rustynes-test-harness/src/bin/coverage_smoke.rs | Adjusts coverage smoke tooling/docs to align with v2.4.0 changes. |
| crates/rustynes-mappers/src/m021_vrc4.rs | Minor v2.4.0-related update (likely documentation/notes alignment). |
| crates/rustynes-mappers/src/m022_vrc2.rs | Minor v2.4.0-related update (likely documentation/notes alignment). |
| docs/STATUS.md | Updates project status/version anchors for v2.4.0. |
| docs/release-notes/README.md | Updates release-notes guidance to reflect current release process/versioning. |
| docs/release-notes/v2.4.0.md | Adds the v2.4.0 release notes content. |
| docs/release-notes/v2.4.1.md | Adds/updates v2.4.1 notes as part of the release-notes flow. |
| docs/libretro/UPSTREAM_SYNC.md | Updates upstream sync guidance (cadence/requirements) referenced by item A. |
| .github/release-notes/v2.3.6.md | Updates older GH release notes file as part of release-notes maintenance. |
| scripts/release-automation/release_v240.sh | Adds/updates automation script supporting the v2.4.0 release process. |
| scripts/release-automation/release_v241.sh | Adds/updates automation script supporting the v2.4.1 release process. |
| AGENTS.md | Records updated upstream-sync cadence rule and related operational guidance. |
| to-dos/ROADMAP.md | Updates roadmap to reflect v2.4.0 deliverables/cadence. |
| to-dos/plans/v2.4.0-concordance-plan.md | Adds/updates the v2.4.0 plan document (items A–D). |
| to-dos/plans/v2.3.9-crucible-plan.md | Updates prior plan doc (context/cross-references for v2.4.0 work). |
| to-dos/plans/v2.3.7-overtone-plan.md | Updates prior plan doc (context/cross-references for v2.4.0 work). |
| to-dos/plans/v2.3.8-parallax-plan.md | Updates prior plan doc (context/cross-references for v2.4.0 work). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Response to the Antigravity reviewBlocking issue — accepted, and it was right in a way worth spelling out. Both swallowed errors are fixed and now propagate.
The parent-directory Both decisions are extracted ( Suggestion 2 (chained symlinks) — accepted as valid, deferred with a reason. Suggestion 1 ( Nitpick (leaked temp file) — declined, deliberately. The Response to the Copilot thread (scratch-name retry)Correct, and fixed. The single retry was justified by "advancing the counter Writing the test for it turned up a second thing: reaching the exhaustion branch |
… flaky test that hid one Review of this PR found three places `write_atomic` reported success it had not earned. All three share a shape worth naming: an error discarded at a call site, under a comment that explains the rest of the operation and reads as though it covered the discard too. set_permissions was swallowed with `let _ =`. The direction is what makes this more than a nitpick: the mode being applied is the mode the target ALREADY had, so a failure replaces a file at 0600 with one at the umask default -- wider than what it replaced -- and tells the caller nothing. A security property silently inverted, not a durability step merely skipped. It now propagates, after removing the scratch file so the original is left intact. The parent-directory sync_all was swallowed along with the File::open that fed it, so the entire durability barrier could be a no-op while the module's own platform table claimed "yes" for Unix. It now propagates, with one deliberate exception: EINVAL, and EBADF on some network mounts, mean this filesystem does not offer a directory fsync rather than the write failed, and failing a save outright on those mounts is a worse answer than proceeding. EIO -- the exact condition the sync exists to detect -- no longer passes as success. The occupied-scratch retry was a single attempt, justified by "advancing the counter cannot repeat a name within a process". True, and beside the point: the collision comes from a PREVIOUS process. A run that crashed mid-session orphans one scratch file per save it made, and pid reuse restarts the counter at zero, so two orphans defeat one retry and the save fails for a reason the user cannot act on. Now a loop bounded at SCRATCH_ATTEMPTS = 8 -- bounded rather than bare, because a directory rejecting creation for a persistent reason would otherwise hang, and a hang is a worse answer than an error. THE TEST THAT WAS ALREADY FLAKY Getting the last one under test surfaced something the suite was not reporting. Reaching the exhaustion branch through write_atomic means predicting the process-global SCRATCH_SEQ and planting a decoy at every name the call will pick -- and that prediction races, because cargo test runs in parallel and every sibling test calling write_atomic consumes sequence values. Measured rather than theorised. A serialising mutex over the three tests that PEEK at the counter still failed 2 runs in 5, because the tests doing the consuming are precisely the ones that never look at it. Which means the pre-existing single-decoy test had been latently flaky since it was written and had simply never lost the race -- it needs one value where the new test needs eight, so it was forgiving enough to hide the defect rather than immune to it. All three decisions are therefore extracted into named functions -- apply_mode_using, directory_fsync_is_unsupported, open_fresh_scratch -- and driven directly. On Unix none of the three failures can be arranged against a file this process just created and owns, so hard-wired call sites would have left every propagation path permanently unexercised. That is how the swallowed versions survived review to begin with, and it is the fourth time this release that "extract it so a test can reach it" was the actual fix rather than a stylistic preference. Eight consecutive module runs are stable at 19 passed. THE WASM32 GATE, AGAIN Once the Unix arm started propagating, the non-Unix sync_parent_dir had to match its signature -- and an always-Ok return is exactly what clippy's unnecessary_wraps objects to, on non-Unix targets only. Native clippy passed; the wasm32 gate did not. Clippy's suggested fix (return unit) would break the parity the shared call site depends on, since write_atomic ends in sync_parent_dir(&target) as its tail expression, so the lint is allowed locally with that reason recorded. This is the second cfg-specific lint this one function has needed, and both were visible only off Unix. DECLINED AND DEFERRED, WITH REASONS Iterative symlink resolution (link1 -> link2 -> missing currently replaces link2 rather than preserving the chain) is real but needs a cycle bound and its own tests; tracked for follow-up rather than bundled here. Pushing the cheats.rs save error to its caller is agreed in principle and is the same class as the swallowed latency-config save, but it is a UI change -- a Result signature plus status-bar plumbing at a call site reached from egui paint code -- and belongs in its own change. Logging the leaked temp file is declined: the cleanup calls ignore their results deliberately, because a cleanup that fails when the disk is full should not displace the primary error. Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32 combinations, the no_std thumbv7em build and rustdoc with warnings as errors. Three mutations confirm the new assertions fail when the old behaviour is restored; a first mutation pass reporting "caught" was rejected on inspection because the mutants had not compiled, which is not evidence of anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Second review round on this PR, and the finding is in the fix from the first one. When the scratch-name loop exhausts, every name it tried was occupied -- that is what exhaustion means. The cleanup below then removed the last one. But that file already existed and belongs to somebody else: an orphan from a crashed run, or a scratch file a colliding instance is actively writing. A save that failed took another process's in-progress data with it. The defect predates the bounded loop -- a single retry could reach it too -- but the loop widened it from one chance to eight, so it arrived with the change that made it likelier. The scratch path is now `Option<PathBuf>`, assigned only after a successful exclusive create, and the cleanup runs only when there is something of ours to clean up. `None` means nothing was created, so anything sitting at those names is not ours to delete. THE FIRST TEST FOR THIS DID NOT TEST IT Worth recording, because the test looked right and passed. It forced a failure by calling `write_atomic` on a directory. That does fail -- but at the *rename*, not at the scratch create, and the rename branch is one where the scratch file genuinely is ours. So the test exercised a path the fix does not touch and passed identically against the defect and against the fix. Two mutations reported NOT CAUGHT, which is the only reason this was noticed. The first restored the unconditional delete and the second restored the exact reported shape -- assigning the scratch path before the create rather than after -- and neither moved the suite. Reaching the real branch means every candidate name colliding, and doing that through the real `scratch_name` means predicting the process-global `SCRATCH_SEQ` and planting a decoy at each name it will pick -- the same race documented on `open_fresh_scratch`. So the name source is now injectable: `write_atomic_with` takes the generator, `write_atomic` passes `scratch_name`, and the test passes a closure returning one fixed occupied name. Exhaustion is then deterministic, and the mutation restoring the reported defect is now caught. That is the fifth time this release that the fix was "extract it so a test can reach it", and the first time the lesson arrived through a test that had already been written and believed. ALSO FIXED, FROM THE SAME REVIEW `RENAME_ATTEMPTS`' doc claimed a 310 ms worst case. The loop returns on the fifth failure rather than backing off after it, so there are four sleeps, not five: 10 + 20 + 40 + 80 = 150 ms. 310 would be the figure if a fifth sleep of 160 ms happened, and it does not. Unchanged from the previous round, with reasons already given on the PR: iterative symlink resolution is deferred (needs a cycle bound and its own tests), pushing the cheats.rs save error to its caller is agreed but is UI plumbing that belongs in its own change, and the ignored `remove_file` results in the error paths are deliberate -- a cleanup that fails when the disk is full should not displace the primary error. Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32 combinations, the no_std thumbv7em build, rustdoc with warnings as errors, and 20 module tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Round 2 — the blocking issue was in the round-1 fix, and it was rightAccepted and fixed. On exhaustion every candidate name was occupied — that is The defect predates the bounded loop (a single retry could reach it too), but the The first test for this did not test itWorth recording, because it looked right and passed. It forced a failure by calling Two mutations reported NOT CAUGHT — one restoring the unconditional delete, one Reaching the real branch needs every candidate name to collide, and doing that Nitpick — accepted, it was a factual error
Repeated suggestions — unchanged, reasons unchanged
Symlink chains: Gates re-run: fmt, workspace clippy, the four frontend feature combos, both wasm32 |
… a mutation found
Third review round: no blocking issues, two suggestions worth taking, and one
finding that came out of checking the second rather than out of the review.
ENOTSUP / EOPNOTSUPP joins the excused set for directory fsync. The set is a list
of ways a filesystem says "there is no barrier available here", and some network
filesystems answer io::ErrorKind::Unsupported where others answer EINVAL. Leaving
one out fails a save on that mount for a path that otherwise fully succeeded --
the same reasoning that put EINVAL and EBADF there.
Parent-directory resolution loses an allocation and reads better: a filter plus
unwrap_or_else over a borrowed Path, rather than a map_or_else building a PathBuf
on both arms.
THE MUTATION FOUND MORE THAN THE REVIEW DID
Mutating that second change -- deleting the filter that maps an empty parent to
"." -- came back NOT CAUGHT. Nothing in the suite covered it.
Path::new("f.txt").parent() is Some(""), not None, and File::open("") fails with
ENOENT. The fallback has been documented as load-bearing since it was written,
and was never tested.
It also matters more now than it did then. While the sync was best-effort,
losing the fallback meant a durability step quietly skipped. Now that the sync
propagates, losing it means write_atomic FAILS OUTRIGHT for any relative target
-- a working call site turned into an error. The property tightened underneath a
test that never existed.
Tested by calling sync_parent_dir directly rather than through write_atomic,
because reaching it that way needs a relative target and therefore a
set_current_dir, which is process-global and races the parallel suite. Same trap
open_fresh_scratch documents; third time in this PR that the deterministic route
was to drive the function rather than the caller.
DEFERRED, WITH THE REASONING
scratch_name appends about twenty bytes, so a target already near the 255-byte
filesystem limit fails with ENAMETOOLONG where a naive fs::write would have
succeeded. Real, and a genuine regression in principle. Deferred because no
current call site can reach it -- save states, per-game config and cheats all name
their files from a SHA-256 hex digest or a ROM-derived stem, none of which
approaches the limit -- and because truncating the base to make room introduces a
collision risk that needs its own design rather than a one-line guard.
Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32
combinations, the no_std thumbv7em build, rustdoc with warnings as errors, and
21 module tests. Both changes mutation-checked; the second one twice, since the
first attempt is what exposed the gap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Round 3 — no blocking issues; both suggestions taken, and one of them found more
The Mutating that second change found something the review did notDeleting the filter that maps an empty parent to
Tested by calling
|
…n followed one level
Fourth review round on this PR. The blocking finding is a consequence of the
first round's fix, which is the honest way to describe it.
Round 1 made the parent-directory sync propagate instead of being swallowed. That
sync is the LAST step, and it necessarily runs after the rename it exists to make
durable -- so its failure returns Err from a call in which the target was
successfully replaced. The doc said "on failure the existing file is left
untouched", which was true when the sync was best-effort and stopped being true
the moment it propagated. A caller reading that error would conclude the old file
survived; it did not.
Rolling the rename back would mean writing the old contents again, turning a
durability warning into a second full write that can itself fail. Swallowing it
again is the defect round 1 fixed. So the contract is corrected instead: the doc
now states which failures happen before the rename and which one happens after,
and the post-rename error is wrapped so its message says the data WAS written and
names what is actually uncertain -- whether the directory entry survives a power
loss. The kind is preserved, so callers matching on io::ErrorKind still see the
real cause.
Broken symlink chains now resolve to their end. canonicalize cannot help here --
it fails outright when the final target does not exist -- so the chain is walked
by hand. Following one level was enough for the dotfiles case that motivated it
and wrong in general: link1 -> link2 -> missing replaced link2 with a regular
file rather than writing through. Bounded at SYMLINK_DEPTH, because a chain can
be a cycle and read_link succeeds forever on one; on exhaustion the last resolved
path is returned rather than an error, since picking a write target is this
function's whole job and a pathological chain should not fail a save. Raised in
three consecutive rounds before being fixed, which is long enough.
The redundant create_dir_all in save_state is removed. write_atomic creates the
parent itself, and doing it twice meant a failure surfaced with one function's
path context or the other's depending on which won the race.
A TEST THAT TESTED THE HELPER, NOT THE CALLER
The first test for the post-rename wrapper called post_rename_sync_error
directly. That asserts the helper behaves and says nothing about whether the call
site uses it -- a mutation deleting the map_err came back NOT CAUGHT. The same
shape as the scratch-cleanup test earlier in this PR, arriving from a different
direction: testing a helper is not testing the code that was supposed to call it.
Fixed by injecting the parent sync alongside the scratch-name generator that was
already injected, so a test can force a post-rename failure and assert the whole
contract at once -- Err returned, message says written, and the target holds the
NEW bytes. The mutation is now caught.
A MUTATION HARNESS THAT POISONED ITS OWN BASELINE
Recorded because the result was confident and wrong. An earlier harness run was
killed by a foreground timeout mid-mutation. Python buffers stdout when not a
tty, so every line it had printed was lost and it looked as though it had done
nothing. It had: the third mutant was still on disk. The next run read that file
as its baseline, so the bounded symlink loop was silently `loop {` in the
BASELINE, stayed there after the run asserted "RESTORED clean" -- true, and
restored to the mutation -- and a cycle test then spun forever against it, with
the timeout attributed to the FIRST mutation. A specific, plausible, wrong
"caught (hang)".
Only a grep for the constant afterwards showed the bound was gone. The harness
now writes a guard file before mutating and refuses to start if one exists,
flushes every print, and warms the build against the real baseline first so a
cold compile cannot be misread as a hang.
DEFERRED, WITH REASONS
The ENAMETOOLONG case stands from the previous round: scratch_name appends about
twenty bytes, so a target near the 255-byte limit fails where a naive fs::write
would succeed. No current call site can reach it -- save states, per-game config
and cheats all name files from a SHA-256 digest or a ROM-derived stem -- and
truncating the base to make room introduces a collision risk that needs its own
design rather than a one-line guard.
Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32
combinations, the no_std thumbv7em build, rustdoc with warnings as errors, and
25 module tests. Three mutations, all caught, against a verified-clean baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The Antigravity reviewer posted a fresh comment each round and DELETED the previous one. That kept the PR tidy and destroyed the record: a round nobody had read before the next push was gone, with nothing on the PR indicating it had ever existed. Unlike a CodeRabbit or Copilot review thread, an unaddressed finding left no trace at all -- so a clean comment list was not evidence that nothing had been raised. Observed on this very PR. Round 1 posted at 13:37:57Z and round 2 at 14:21:35Z; afterwards the issue-comments endpoint returned exactly ONE bot comment, with created_at equal to updated_at equal to 14:21:35Z. The first was gone -- not edited, since the timestamps would differ, and not appended to. Both of those rounds raised a blocking issue and both were correct, one of them a data-loss defect, so the cost of losing a round is not hypothetical. There is now ONE comment per PR, edited in place: the newest round on top, every earlier round folded into a collapsed <details> block beneath it. Same tidiness, nothing destroyed. The script issues no DELETE at all any more, and the selftest asserts the absence of one so the behaviour cannot return unnoticed. The archive is bounded by MAX_BODY_BYTES (60000, under GitHub's 65536 hard limit) because a PR with many pushes would otherwise grow it until an EDIT starts failing -- stranding the comment at whatever round last fit, which is the worst possible failure since the newest review is the one that cannot be posted. Oldest rounds drop first, and the drop is ANNOUNCED in the body: a silent truncation would look exactly like a PR that had only ever been reviewed once, which is the confusion this whole change exists to remove. Every failure path falls back to a plain post of the new review. A duplicate comment is noise; failing to publish a review is not. THE FORMAT LIVES IN ITS OWN FILE, AND THAT IS THE POINT scripts/_agy_comment_body.sh holds the sentinels and the split/trim helpers, and is sourced by both the reviewer and the selftest. agy-review.sh does its work at top level and so cannot be sourced, which is exactly how a test ends up reimplementing its subject -- and that happened here. The first version of these checks inlined its own copy of the awk pipeline, so a mutation deleting the marker strip from the script came back NOT CAUGHT. A test that reimplements what it tests agrees with itself forever. The fixture changed for the same reason. It had our own bot's comment first, so `first` selected it whether or not the author filter was present -- the security control that stops any user from putting the marker in a comment and having the bot edit it was untestable. A User comment carrying the marker now sorts ahead of ours, and deleting the filter fails. Eight mutations, all caught: the author filter, empty-versus-null, oldest-versus- newest selection, a reintroduced DELETE, the marker strip, the archive split's sentinel ordering, dropping the newest round instead of the oldest, and a drop that succeeds on an empty archive (which would spin the trim loop). Verified end to end by simulating four rounds through the real functions: all four findings present in the final body, newest first, marker appearing exactly once. INSTALLER AND WORKFLOW _agy_comment_body.sh is REQUIRED, not optional -- agy-review.sh sources it at startup, so an install without it fails at runtime rather than degrading. install-into-repo.sh now copies it and the selftest, the workflow chmods it, and both temporaries the archive path creates are pre-declared so the cleanup trap frees them on every exit including the early one after a successful edit. THIS DOES NOT TAKE EFFECT UNTIL IT MERGES The workflow checks out the DEFAULT BRANCH to run the scripts, so a change to agy-review.sh has no effect on any PR -- not even the PR that makes it. The README's default-branch rule covered the workflow and comment triggers; it now covers the scripts too, since that is the surprising half. Synced byte-identical to the canonical template at Local_Only-Projects/antigravity-pr-review/, including the timeout-minutes bound this repo had added locally. The four sibling repos keep the old behaviour until install-into-repo.sh is re-run against them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The Antigravity reviewer's checkout used `actions/checkout@v7`, a tag that moves. RustySNES and SLAC already pinned it to a SHA; the template and this repo did not, so hardening was flowing the wrong way between copies of the same file. It matters more here than on a hosted job. This workflow runs on a SELF-HOSTED runner -- the maintainer's own machine, holding the agy CLI's Google AI Ultra OAuth session -- so a compromised tag executes there rather than in a disposable VM. The same reasoning already applied to `dtolnay/rust-toolchain` in this repo's CI, and this was the remaining unpinned action on the highest-trust runner. The SHA was verified rather than copied: `actions/checkout` tag v7 resolves to 3d3c42e5aac5ba805825da76410c181273ba90b1, the "prep v7.0.1 release" commit of 2026-07-17. The trailing `# v7` is the form Dependabot's github-actions ecosystem reads to keep the pin current, so it is not decoration. Synced to the canonical template, which now carries the pin for every future install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…ction Two mechanisms existed in exactly one of the five installs. Both belong in all of them, and the sweep that unified the comment-archive behaviour is the right moment to say so. A BACKEND OUTAGE POSTED AS A PASSING REVIEW When agy's upstream is down it prints an error rather than a review: Error: Eligibility check failed: UNAVAILABLE (code 503): The service is currently unavailable. That text is non-empty, so have_text() treated it as a valid review, POSTed it as the review comment, and the job exited 0 -- a green check for a review that never ran. Observed twice on SLAC PR #14, where the check passed in seven seconds with that string as its entire body. A control that cannot fail is worse than no control. The match is deliberately ANCHORED to the start of the capture rather than being a substring search, and it is bounded by size. A genuine review may quote a 503 or an UNAVAILABLE constant while reviewing retry logic, and aborting on that would be the false positive the OAuth guard's design notes warn about. A backend failure IS the whole capture and begins with `Error:`, so requiring the error on line one, in a capture short enough to contain nothing else, separates the two without a content heuristic. A backend error is transient, so it retries like empty output rather than aborting the way a lapsed session does -- but the capture is blanked so no later path can post it. The tally is a COUNTER, not a per-attempt flag: a boolean reset each attempt reflects only the last one, so a 503 on attempt 1 followed by empty output on attempt 3 would report the wrong cause. Both exit non-zero, so nothing unsafe -- but the log line is the only thing telling a human which outage they are looking at. MARKER-BASED EXTRACTION, AND WHY IT IS BETTER The selftest lifted the jq filter out of the reviewer by matching the declaration's own syntax: a sed range ending at the first line closing with a quote. A filter whose body ever ended a line that way would be SILENTLY TRUNCATED, and a truncated jq program can still compile and still return ids -- the exact silent-wrong-answer that file exists to prevent. Explicit `SELFTEST-EXTRACT` markers replace it. They also let a guard be several statements rather than one assignment, which is what makes the OAuth and service-error guards testable at all. Every marked block is now asserted to exist, to be valid shell, and to be sourceable, because a renamed marker would extract EMPTY -- and an empty guard sources fine and asserts nothing. WHAT THE MUTATIONS CHANGED Three of six came back NOT CAUGHT on the first pass, and two were real. The anchor could be deleted with every check still passing, because the fixture for "a review discussing a 503" put the error on line 3, where `head -n 1` already excluded it. A fixture whose FIRST line contains the error text mid-line -- which only `^` can reject -- now covers it. The persistent-outage abort was checked by grepping the script for its condition, which `if false && [ ... ]` still satisfies. That decision is now a named function, `backend_outage_should_fail`, called by the test rather than grepped for; three mutations of it are caught where the grep caught none. `have_text` moved inside the marked block so the block is self-contained -- the marker is a comment, so nothing about where the function is defined changed. The third, removing the `[ -s ]` empty-file check, is an EQUIVALENT mutant and is recorded as such rather than papered over with a test: an empty capture yields no grep match either way, so the check is defensive and its removal is unobservable. Superset verified rather than assumed: every non-comment line SLAC had before this sweep is either present in the template or is old delete machinery this design removes, plus a large-diff fallback the template supersedes -- SLAC's copy handled GitHub's 20,000-line limit only, the template's handles the 300-FILE limit too. All five installs now run one implementation. Selftest passes and actionlint is clean in each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…e caught one Both were on the PR's own run, and both are mine. A RUSTDOC LINK I CLAIMED TO HAVE CHECKED `write_atomic` is public and `post_rename_sync_error` is private, so an intra-doc link between them fails `rustdoc::private-intra-doc-links` under `-D warnings`. Now a plain code span, which is the rule this repository already applies to feature-gated dependency names. The commit that introduced it listed "rustdoc with warnings as errors" among its gates. That claim was false: the full gate run predated the last edits, and I did not re-run it before committing. The lint is exactly the sort a green earlier run cannot vouch for, which is the whole reason the gate is meant to be re-run rather than remembered. Recorded plainly because the failure was the claim, not the link. THE WORKFLOW AND THE SCRIPTS COME FROM DIFFERENT REFS The reviewer workflow checks out the DEFAULT BRANCH to get its scripts -- that is deliberate, and documented, so a fork's code never executes on the self-hosted runner. But for a `pull_request` event GitHub runs the workflow YAML itself from the PR BRANCH. So the two halves come from different refs, and a change spanning both breaks its own PR. The previous commit added `scripts/_agy_comment_body.sh` and added it to the workflow's chmod; the job then died with chmod: cannot access 'scripts/_agy_comment_body.sh': No such file or directory because the checkout was of `main`, which does not have the file yet. The surprising half is the inversion: the default-branch rule is documented for the scripts -- a change to agy-review.sh has no effect until it merges -- and the corollary is that the workflow moves IMMEDIATELY while the scripts do not. That runs against the usual intuition that everything in a PR is consistent with itself. The workflow half now tolerates both script sets: the two required files are chmod'd unconditionally, anything added later only if present, with a trailing `true` so a false `[ -f ]` cannot fail the step under `bash -e`. A genuinely missing required file still fails loudly, because agy-review.sh sources it and dies -- the tolerance is in the workflow, not in the contract. Swept to the canonical template and to all four sibling installs, whose open PRs would have hit the identical failure on their next run. Gates, re-run in full this time: fmt, workspace clippy, the four frontend feature combos, both wasm32 combinations, the no_std thumbv7em build, rustdoc with warnings as errors, 573 frontend tests, the reviewer selftest, and actionlint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…t hid it
`is_transient_rename_error` was a `const fn` whose body called `io::Error::kind`.
That method is not `const`, so the call is E0015 -- but the call sat behind
`#[cfg(windows)]`, so the body was never compiled on Linux and every local gate
and every PR check passed.
PR runs here are Linux-only; the full matrix runs on `main`. So this would have
turned `main` RED AFTER MERGE, on the branch where a red build blocks a release,
rather than failing the PR that introduced it. Caught in review, not by a gate.
Verified before being believed. A plausible reviewer claim had already proved
false once this release, so the language question was settled against a minimal
two-line crate: `const fn f(e: &io::Error) -> bool { matches!(e.kind(), ...) }`
gives `error[E0015]: cannot call non-const method std::io::Error::kind in
constant functions`. It was also checked against `main` -- the function is new in
this PR, so nothing shipped broken.
THE FIX IS NOT JUST DROPPING `const`
Dropping it would fix this instance and leave the mechanism intact: any Windows-
only code behind a `#[cfg]` is invisible to a Linux PR build, so the next error in
it would land the same way.
The Windows predicate now lives in an always-compiled function, reached through
`cfg!(windows) && is_windows_sharing_violation(e)` rather than a `#[cfg]` block.
`cfg!` is a compile-time boolean inside an ordinary expression, so the predicate
is parsed, type-checked and borrow-checked on every platform, while `&&`
short-circuits it away on non-Windows and the optimizer drops the branch. Runtime
behaviour is identical; what changes is that a Linux `cargo check` now compiles
the Windows logic.
The proof is that restoring the `const` NOW FAILS ON LINUX, with
`error[E0015]: cannot call non-const function is_windows_sharing_violation in
constant functions`. The defect class moved from "invisible until another platform
builds it" to "fails the PR".
Two tests come with it: the sharing-violation predicate is exercised on whatever
platform the suite runs, and a Unix-only test pins that the public predicate stays
unconditionally false, so `cfg!` did not quietly change the single-attempt
guarantee. Three mutations caught -- the predicate inverted, the `cfg!` guard
dropped so Unix would retry, and the symlink bound reduced -- and a fourth,
restoring `const`, is now a compile error rather than a silent pass.
SYMLINK_DEPTH MATCHES THE KERNEL
Raised from 8 to 40, Linux's own MAXSYMLINKS. The old value was justified as "far
past any real dotfiles arrangement", which is true and beside the point: where the
kernel would resolve a chain and this function gives up, the two disagree about
where the file IS, and the write lands somewhere the user did not mean. It costs
one `read_link` per level, on a chain already known to be broken.
DECLINED, WITH REASONS
A `Drop` guard removing the scratch file if `write_all` panics: the bounded
scratch loop exists precisely so an orphan is survivable, and it now advances past
up to eight of them. Adding an unwinding path to buy what the retry already
handles is not worth the surface.
SCRATCH_ATTEMPTS staying 8 rather than a rounder 5 or 10: the number is bounded by
"how many scratch files one crashed run can plausibly orphan", and 8 is already
generous for that. Rounding it changes nothing measurable.
Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32
combinations, the no_std thumbv7em build, rustdoc with warnings as errors, and 579
frontend tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Round 5 — the blocking finding was right, and no local gate could have caught itCorrect, and it would have broken Verified before believing it, since a plausible reviewer claim proved false once Dropping
|
Raised as blocking in three consecutive review rounds before I stopped deferring it, and the deferral was wrong on its facts. `cheats::save` wrote its error to `stderr`. On a windowed build nobody reads `stderr`, so a save that failed looked exactly like one that worked and the user lost their cheat list for that ROM with no signal at all. Same defect class as the swallowed latency-config save fixed in #411, in a PR whose entire subject is not swallowing save errors. WHY I DEFERRED IT, AND WHY THAT WAS WRONG The stated reason was that the fix needed UI plumbing: a `Result` signature plus a status-bar path from egui paint code with nowhere to put an error. The second half was false. The panel already carries `error`, `raw_error` and `enc_error`, each rendered with the same `colored_label` idiom and each cleared on a ROM change. The place to put it already existed; I asserted otherwise without looking. WHAT IT LOOKS LIKE NOW `save` returns `io::Result<()>`, with the `create_dir_all` failure propagating rather than printing-and-returning, and a serialization failure mapped to `InvalidData` -- the caller has one error channel and one thing to tell the user, so a bespoke error type for a case these types cannot produce would be ceremony. `persist_cheats` takes `&mut CheatPanelState` and records the outcome, clearing the field on success so a fixed problem stops being reported. It is cleared on a ROM change too, because a save error names the PREVIOUS ROM's cheat file and carrying it across would report a failure against a game it never touched -- the stale-panel-state seam this project has hit three times. The panel renders it ABOVE the lists rather than beside the add-fields, because the message is not about any single edit: it says the whole list on screen is not on disk. A test forces the failure by pointing the data dir at a regular file, so `create_dir_all` cannot create the parent, and asserts an error reaches the caller at all. The three existing round-trip tests now assert the save succeeded rather than discarding its result. ALSO: THE REVIEW COMMENT NO LONGER GOES THROUGH ARGV `gh api -f body="$(cat "$body_file")"` passed the whole comment as a single execve argument. At `MAX_BODY_BYTES` that approaches 60 KB against a `MAX_ARG_STRLEN` of 128 KB on Linux -- close enough that raising the bound later would start failing with E2BIG, and the failure would read as a GitHub error rather than a local limit. It is now `jq -n --rawfile b "$body_file" '{body: $b}' | gh api ... --input -`. Nothing traverses argv, and `--rawfile` makes the value a JSON string by construction, so neither shell quoting nor `-F` type-coercion can reinterpret a body that happens to look like a number or a boolean. Swept to the template and the four sibling installs. Declined, with the reason unchanged: logging a failed `remove_file` in the cleanup paths. The result is discarded so the PRIMARY error survives, and a cleanup that fails when the disk is full should not displace the error that matters. Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32 combinations, the no_std thumbv7em build, rustdoc with warnings as errors, 580 frontend tests, and the reviewer selftest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Round 6 — the blocking finding was right, and my three deferrals were wrong on their facts
The second half was false. The panel already carries
|
…ys so Round seven found no blocking issues. Its useful finding was a nitpick: the timeline counter's documentation explained the whole design and never answered whether a save state carries it. It does not, and both ways of getting that wrong are silent. The counter is not written by `snapshot`, not read by `restore`, and a loaded state does not carry its own value across. A restore instead ADVANCES the live counter, which is the correct reading of the event -- the timeline you were on has been replaced -- and is true regardless of which state was loaded. Serializing it would break two things without any visible symptom. Loading the same slot twice would restore the same generation twice, so a consumer comparing against its last-seen value would miss the second load entirely. And a value from another session says nothing about this one: the counter is only meaningful against the previous value THIS process observed, which is why the accessor already documents that comparing it across two `Nes` instances is meaningless. Because it lives outside the snapshot, `snapshot_schema_audit` cannot see it -- the very property that makes the design correct also means nothing mechanical would notice the reasoning being invalidated. So the behaviour is pinned by a test instead: a restore advances it, a SECOND restore of the SAME slot advances it again (the assertion that fails if it were ever serialized), and a fresh `Nes` restored from that state counts its own restores rather than inheriting a stored value. THREE SUGGESTIONS WERE ALREADY IMPLEMENTED Recorded because re-raising them is cheap and re-verifying them is not. The symlink resolver already has a hard cap. It is 40, matching Linux's MAXSYMLINKS, and `a_symlink_cycle_terminates` covers `a -> b -> a`. Round five of this same review asked for the kernel's number where I had 8; round seven suggests 8 or 16. Holding at kernel parity, for the reason round five gave: where the kernel resolves a chain and this function gives up, the two disagree about where the file IS. The comment archive is already bounded. MAX_BODY_BYTES is 60000 against GitHub's 65536, oldest rounds drop first, and the count of dropped rounds is printed in the body rather than truncating silently. The scratch file is already cleaned up on an early return via `?`. The `?` returns from the CLOSURE, not from `write_atomic`, so `write_result` is `Err` and its cleanup runs. `a_failed_write_leaves_the_original_intact` now asserts no `.tmp` survives -- and its comment states precisely which branch that covers, because a mutation showed it reaches the RENAME-failure cleanup rather than the write-failure one. Removing the branch it does reach is caught; removing the other is not, and the test says so rather than being read as covering both. The two `skip_serializing_if` fields were checked rather than assumed: both carry `#[serde(default)]`, so a config written before them still loads. `rustynes-core` changes, so the accuracy gates are VERIFIED rather than asserted: AccuracyCoin 141/141 (100.00%, RAM decoder; the framebuffer decoder reports 120 and is known-buggy) and nestest 0-diff. Full clippy matrix, both wasm32 combinations, no_std thumbv7em, rustdoc with warnings as errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Round 7 — no blocking issues. The nitpick was the useful finding; the three suggestions were already implemented.
|
Round eight, and the blocking finding is a data-corruption bug in code from round two of this same PR. `agy_drop_oldest_round` located the oldest archived round by matching `/^<details>$/` -- the tag itself. A review body legitimately contains `<details>` blocks: folded logs, collapsed code, another bot's summary, and the archived rounds are themselves nested `<details>`. So the cut could land INSIDE a round, leaving torn HTML and half a review in a comment nobody would think to check. It is now delimited by `AGY_ROUND_MARK`, an HTML comment the writer emits ahead of each round. Invisible when rendered, and it cannot occur by accident in prose the way a tag can. This is the same mechanism the archive boundaries already used, and the same mechanism this PR adopted from SLAC one commit earlier for exactly this reason -- that a `sed` range matching a declaration's own syntax truncates silently. I used markers for the outer boundaries and a naive regex for the inner ones in the same file, which is the kind of half-application that reads as consistent until someone tries it with real content. FAIL-SAFE FOR ARCHIVES ALREADY IN THE WILD An archive written by the previous version has no round markers. With none present the function exits non-zero and NOTHING is dropped, so the edit fails on size -- recoverable, visible, and repaired by the next round -- rather than the archive being silently mangled. Pinned by its own check. Three checks and two mutations. The nested case is the one that matters: a round carrying its own `<details>` must survive the oldest being dropped, and restoring the tag-matching form fails it. Also asserted: the writer actually EMITS the sentinel, because if it did not, every archive would look legacy-shaped, the trim would silently never fire, and the comment would grow until the edit failed. ALSO FROM THIS ROUND `cheats::save` no longer calls `create_dir_all` before `write_atomic`, which creates the parent itself. Doing it twice meant a failure surfaced with one function's path context or the other's depending on which won -- the same redundancy already removed from `save_state.rs`. Swept to the canonical template and the four sibling installs; selftest passes in all five. Full clippy matrix, both wasm32 combinations, no_std thumbv7em, rustdoc with warnings as errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Round 8 — the blocking finding was right, and it was my bug from round 2Accepted and fixed. Now delimited by What makes this worth recording is that it's the same mechanism this PR adopted Fail-safe for archives already writtenAn archive from the previous version has no round markers. With none present the Three checks and two mutations. The nested case is the one that matters: a round
|
…ertion that pins it Round nine found no blocking issues. Two of its suggestions were worth acting on, and getting the first one under test exposed two holes -- one of which I had just created. THE RETRY BUDGET Review reports that Windows Defender can hold a lock on a newly written file for longer than the 150 ms the retry loop waited, so the retry would exhaust before the lock cleared and the save would fail for a reason that resolves itself a moment later. Eight attempts now give 10+20+40+80+160+320+640 = 1270 ms. THAT TIMING CLAIM IS NOT ONE THIS PROJECT CAN VERIFY. There is no Windows runner in the PR matrix and no measurement behind it here. It was adopted anyway because the trade is one-sided: the cost is a longer stall on a save that is ALREADY FAILING, and the benefit is not losing a save the user believes happened. The comment says so, and names itself as the place a real number belongs if anyone measures one. TWO HOLES, BOTH FOUND BY MUTATION Raising the count made the exhaustion test really sleep 1270 ms -- measurably, 0.15 s to 1.27 s for the module. The base backoff is now a parameter and tests pass 0, which changes nothing they assert: the loop's contract is the attempt COUNT and the propagated error, not wall time. That injection created a hole. A mutation zeroing the PRODUCTION backoff came back NOT CAUGHT, and it is not a weak test: on Unix `is_transient_rename_error` is always false, so the loop never sleeps and there is nothing to observe on the platform CI runs. Only a Windows run could see it. Declared in the module's existing "what the tests do NOT cover" section rather than papered over. The second hole was already there. `assert_eq!(calls, RENAME_ATTEMPTS)` pins NOTHING -- both sides move together, so reducing the constant satisfies it while silently halving the budget, and that mutation came back NOT CAUGHT. The property is now asserted through a derived `total_backoff_ms()`: at least 500 ms in total, which is the requirement the count was chosen to meet rather than a restatement of the count. The mutation is caught. `total_backoff_ms` is `#[cfg(test)]`: nothing in the shipped path needs the sum, the loop sleeps per attempt. A derivation of two production constants that exists so a test can assert what they were chosen for is a fine reason for a test-only item and a poor reason to keep dead code in the binary. THE PANEL ERROR IS WRAPPED, NOT TRUNCATED An OS-level I/O error can be long, and an unwrapped label widens the window to fit it -- the panel's other error fields carry short messages this crate authored, so they never showed the problem. Wrapped rather than truncated because the tail of an I/O error is usually the part naming the actual cause. THREE SUGGESTIONS DECLINED, WITH REASONS The archive cap is already implemented -- MAX_BODY_BYTES against GitHub's 65536, oldest dropped first, count announced. Third round it has been raised. Documenting the `timeline_generation` serde trap: already done in the previous commit, and the premise is wrong. There is no `#[serde(skip)]`; `rustynes-core`'s `nes.rs` contains zero `serde` at all -- the snapshot is a hand-written binary format. The field is excluded by not being written, which is what the new documentation and its test describe. A startup sweep of orphaned scratch files: declined on a hazard, not on effort. Two RustyNES instances can run at once, and the sweeper cannot tell a dead run's orphan from a live run's in-flight scratch file without checking whether the pid is alive -- which is racy and platform-specific. Deleting a live instance's scratch mid-write is a worse failure than the clutter it cleans. The bounded scratch loop already makes orphans survivable. Logging the excused directory-fsync errnos: this crate has no logger. Adding a logging dependency for one debug line is not the trade today, and the errnos are named in the code with the reason each is excused. Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32 combinations, the no_std thumbv7em build, rustdoc with warnings as errors, and 580 frontend tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Round 9 — no blocking issues. Two suggestions taken; getting one under test exposed two holes.The retry budget — taken, on an unverified premise, stated as suchYou report that Defender can hold a lock longer than the 150 ms the loop waited. I can't verify that timing claim — there's no Windows runner in the PR matrix Two holes, both found by mutatingRaising the count made the exhaustion test really sleep — 0.15 s → 1.27 s for the That injection created a hole. A mutation zeroing the production backoff came The second hole was already there. The panel error — wrapped, not truncatedRight that a long OS error would blow out the layout. Wrapped rather than Declined, with reasonsThe archive cap is already implemented — The A startup sweep of orphaned scratch files — declined on a hazard, not on Logging the excused fsync errnos — this crate has no logger. Adding a logging Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32 |
Antigravity review (Gemini via Ultra)This PR extracts atomic file writing into a robust, platform-aware shared utility to prevent data loss across configs, save states, and cheats, introduces a timeline generation counter to safely reset debug telemetry upon timeline jumps, avoids serializing empty TOML tables for opt-in config maps, and updates the libretro upstream version string. Blocking issuesNone found. Suggestions
Nitpicks
Automated first-pass review by |
…GA device-under-test (#429) * feat(cosim): RustyNES as a co-simulation oracle for an FPGA device-under-test Opens the v2.4.1 - v2.5.0 "Fabric" line (ADR 0037): a new NES core written in SystemVerilog from public hardware documentation, in a sibling RustyNES_MiSTer repository, verified against this emulator. This commit lands the half that lives here -- the boundary between the two -- plus the decision record, the execution plan, and the research archive behind them. RustyNES is not being ported to FPGA and cannot be. A MiSTer core is SystemVerilog compiled by Quartus 17.0.2 into a Cyclone V bitstream; Rust does not become a bitstream, and high-level synthesis of a cycle-accurate emulator's control flow does not produce usable hardware. What is buildable is a new implementation with RustyNES as its verification oracle -- the one role it is uniquely equipped for, and the reason to attempt this here rather than elsewhere. The reference firewall extends to HDL accordingly: NES_MiSTer and fpganes rtl/ are strict black boxes, instantiable as opaque modules to compare outputs, never readable as source. WHAT LANDS crates/rustynes-cosim is a pure wrapper. It adds no core API, changes no core behaviour, sits outside workspace default-members (verified through cargo metadata, not assumed), and exposes two surfaces: a narrow C ABI a Verilator testbench links, and the safe Rust API a golden-export binary uses. The nes_golden_export CLI emits five formats -- the CpuBootTrace binary, the per-cycle IRQ/bus CSV, a pre-palette u16 index framebuffer, the 2 KiB work RAM the AccuracyCoin RAM decoder reads, and a provenance manifest. Two design choices are load-bearing and recorded so they are not re-litigated. Replay rather than lockstep, because Nes exposes run_frame() and step_instruction() and nothing finer -- cycle-lockstep would mean new core API on the hot path, and it would gain nothing, since the determinism contract already makes a pre-recorded trace exactly the trace a lockstep run produces. And the framebuffer is exported pre-palette, so a palette difference cannot masquerade as a rendering difference: the failure mode v2.3.8 "Parallax" was built to prevent. TWO FINDINGS THE CRATE WAS NOT LOOKING FOR No CI invocation had ever enabled cpu-boot-trace or irq-timing-trace for clippy, so those two rustynes-core modules had never passed the lint gate. Turning them on surfaced six pre-existing errors -- four missing_const_for_fn, one manual_is_multiple_of, one collapsible_if -- all fixed here. The workspace gate reads as exhaustive and is not: --workspace --all-targets covers each crate's DEFAULT feature set, so a cfg-gated module is invisible to it. All six sites are behind default-off features, so the shipped core is untouched by construction; the collapsed if keeps its is_some() guard as the first arm of a let-chain, because take() clears trace_a12_latest and short-circuiting is what keeps that from running when tracing is off. This crate therefore enables both features UNCONDITIONALLY rather than re-exposing them as its own optional features. A build without them would compile, link, run, and export empty goldens -- an absence of signal that reads exactly like agreement. Mandatory turns that into a compile error. And the first run_frame() after power-on advances zero cycles. The PPU is constructed at dot 340 of the pre-render line, so the 7-cycle reset sequence ticks past the frame wrap and leaves frame_complete latched; the first call consumes the latch and returns having stepped nothing. Measured, not inferred: frame 0 advances the cycle counter by 0, frames 1..3 by ~29,780 each. Every other caller in the workspace runs thousands of frames, so one lost frame is invisible to them -- but a bare loop would have emitted an (n-1)-frame golden under a manifest claiming n, a provenance record wrong in the one direction that matters, since a DUT compared against it would be off by a frame for reasons nothing in the record explains. Oracle::advance_frames gates on the frame counter instead, with a jam bail-out, and the manifest records requested and actual separately because they can legitimately differ. VERIFICATION Three new assertions, each proven by mutation before being trusted: counting calls instead of frames is caught, an off-by-one target is caught, and removing the jam bail-out is caught as a HANG rather than a failure -- which is why the mutation harness needed a timeout to register it at all. rustynes-core changed, so the accuracy gates are verified rather than asserted: AccuracyCoin 141/141 (100.00%, RAM decoder -- the framebuffer decoder reports 120 and is known-buggy) and nestest 0-diff. Workspace 2226 passed across 128 suites, 0 failed. fmt, workspace clippy, the four frontend feature combos, both wasm32 combinations, the no_std thumbv7em build and rustdoc with warnings as errors all pass. SCOPE, STATED RATHER THAN DISCOVERED v2.5.0 is scoped to "the 6502 rung closes" -- the harness plus a cycle-exact 6502, gated on nestest 0-diff and per-cycle bus equality. A from-scratch cycle-accurate NES core is 7-13 months FTE against a two-to-four-week window at demonstrated cadence, so PPU, APU and MiSTer integration become the v2.6-v2.9 programme. Two risks are accepted in writing: NES_MiSTer already scores 121/125 on AccuracyCoin where real Famicom AV hardware also scores ~121/125, so there is no published accuracy headroom and the core may be declined as a duplicate; and the oracle can be wrong, since 141/141 is not "matches silicon", so every rung is labelled by whether it has an independent oracle. Docs: ADR 0037, docs/mister.md, to-dos/plans/v2.5.0-fabric-plan.md, and the research archive at to-dos/plans/research/. AGENTS.md gains the HDL firewall extension and three operating notes. Addresses the "Fabric" programme opening; no issue is finished by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * test(cosim): the rung-0 gate, and a demonstration that it can fail The "Fabric" ladder's rung 0 is stated as: feed RustyNES's own golden back in as if it were the device-under-test and get zero divergences. Without it, every later red result is ambiguous between "the RTL is wrong" and "my writer packs a field wrong" -- and early on the second is far likelier. Three tests, and the second and third exist because the first alone proves less than it appears to. The self-diff asserts two independent exports of the same ROM and seed are byte-identical across the boot trace, the index framebuffer, work RAM, the cycle count and the call count. This is the determinism contract observed at the boundary this crate exposes: if it fails, a pre-generated golden is not the trace a lockstep run would have produced, and replay-as-oracle is unsound rather than merely inconvenient. The corruption test exists because a comparator that always reports agreement passes a self-diff trivially. One flipped bit in the middle of the trace is the smallest divergence an RTL bug could plausibly produce, and it must not compare equal. The frame-count test checks the count WITHOUT trusting the counter that produced it: five frames must be five NTSC frames' worth of CPU cycles, and it must take six run_frame() calls to get there, because the first after power-on is swallowed by the frame_complete latch the reset sequence leaves set. Done in integer half-cycles rather than floating point -- the whole job is an arithmetic check, and doing it in f64 would have meant two lossy casts and a lint suppression to verify five frames. VERIFIED BY HAND FIRST, THEN BY MUTATION Exported against the committed mmc1_a12 ROM and checked three ways, because a manifest that agrees with itself is not evidence. The recorded rom_sha256 matches an independent sha256sum. run_frame_calls = 6 for frames_actual = 5 makes the power-on latch visible in the record rather than hidden by it. And 148905 cycles divided by 29780.5 is exactly 5.000 frames, where a bare loop gives 4.0. The real cpu_boot_trace_diff CLI reports "All 5464 aligned records match" on the self-diff and exits 0; the same CLI against a one-bit corruption reports the divergence at cycle 561, PC $C419, naming the field and both values. So the zero is a signal from a tool that can tell, not from one that cannot. Then mutated: advance_frames reverted to the bare `while calls < frames` loop that motivated it. Caught, with the magnitude quantified -- 119123 cycles against an expected 148902, short by 59559 half-cycles, which is exactly one frame. Gates: fmt and clippy clean for the crate; 8 unit plus 3 integration tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * docs(mister): record the null-DUT gate that now backs rung 0 The spec described rung 0's acceptance criterion as something to build. It is built for the half that lives in this repository, so the document should say what exists rather than what is planned -- including that the zero was checked against the real cpu_boot_trace_diff CLI and shown to be able to report non-zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * docs(agents): the Antigravity reviewer replaces its comment, it does not append The ceremony rules already record that this reviewer posts as a plain issue comment, invisible to both reviewThreads and reviews[].body. There is a sharper hazard on top of that, observed on PR #428. Round 1 posted at 13:37:57Z and round 2 at 14:21:35Z. Afterwards the issue-comments endpoint returned exactly ONE bot comment, created_at equal to updated_at equal to 14:21:35Z. The first was gone -- not edited, since the timestamps would differ, and not appended to. Deleted and replaced. CodeRabbit and Copilot threads persist and can be resolved, so an unread finding stays visible. An Antigravity finding does not, and nothing on the PR indicates a round ever existed. A clean comment list is therefore not evidence that nothing was raised. Both rounds on that PR were blocking and correct, one of them a data-loss defect, so the cost of missing a round is not hypothetical. The rule that follows: read the comment before every push rather than after, and quote its findings into the reply, since the reply persists and the original does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * docs(agents): the reviewer's destructive replacement is fixed, and where it is not The note written when the behaviour was discovered described it as current. v2.4.0 fixed it, so the note now records what happened, what replaced it, and the two places the fix does not yet apply. The mechanism is worth keeping in full rather than trimming to "fixed": it is the reason the bot ceremony has to be run before a push rather than after, and that rule still binds on the four sibling repos until their own PRs land. Two details added that the original note could not have had. Rounds are delimited by an HTML-comment sentinel rather than the `<details>` tag, because a review body legitimately contains `<details>` blocks and matching the tag cut inside a round -- found in review one commit after adopting markers elsewhere in the same file for exactly that reason. And the default-branch rule has a corollary that bit once: the workflow YAML comes from the PR branch while its checkout fetches the default branch, so a change adding a script file breaks its own PR unless the workflow half tolerates both script sets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(cosim): the rung-0 gate cannot be an integration test in this crate CI's `cargo test --workspace --release --features test-roms` failed to compile the `null_dut_self_diff` target with nineteen errors of the form: error: the crate `sha2` requires panic strategy `abort` which is incompatible with this crate's strategy of `unwind` This crate declares `crate-type = ["rlib", "staticlib", "cdylib"]` because a Verilator testbench links the C ABI. Under `--release` the workspace profile sets `panic = "abort"`, so the dependency graph is built with that strategy -- and an INTEGRATION test in `tests/` is a separate binary that links the rlib and needs `unwind`. The two cannot share those dependencies. The three checks now live in the lib's `#[cfg(test)]` module beside the eight that were already there. Nothing is lost: they drive the crate's public API exactly as an external consumer would, which is the property that made them integration tests in the first place. The reason is recorded at the site, because the next reader will want to move them back. `rustynes-libretro` is the same shape and has never hit this, and comparing the two is what identified the mechanism rather than the symptom: it declares `cdylib` and `staticlib` with NO `rlib`, and keeps its tests `#[cfg(test)]` in `src/`. That is the working configuration for a crate in this workspace that exports a C ABI. WHY NO LOCAL GATE CAUGHT IT It compiles clean in debug. My gate script runs `cargo test` without `--release`, and `cargo test --release -p rustynes-cosim` alone also passes -- the failure needs the workspace-wide release build with the harness features, which is precisely what the CI job runs and what I had not run locally. That is the same shape as the two other misses in this line: a gate that exists but does not reach the configuration in question. The wasm32 combinations were one, `#[cfg(windows)]` code never being type-checked on Linux was another, and this is a third -- release-profile linking of a multi-crate-type crate. 11 tests pass in the crate. Verified where it counts rather than by inference: under the exact CI invocation (`cargo test --workspace --release --features test-roms`), `rustynes-cosim` now compiles with ZERO panic-strategy errors -- the crate that previously produced nineteen. The rest of that build is the test harness's fat-LTO link and is left to CI, which is the authoritative gate for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(cosim): a dot in a ROM name ate the golden's filename, and two swallowed errors Three of the four blocking findings from review, all confirmed before acting and one of them a corrupted-output bug rather than a style point. A DOT IN THE ROM NAME TRUNCATED EVERY GOLDEN `Path::with_extension` replaces everything after the LAST dot. A ROM named `Super Mario Bros. 3.nes` has a stem of `Super Mario Bros. 3`, so `with_extension("ram.bin")` produced `Super Mario Bros.ram.bin` -- the frame number silently eaten, and every one of the five goldens landing under a name that is not the one the manifest describes. Verified rather than reasoned about: a two-line program prints exactly that. Dots are common in NES filenames, so this would have corrupted real golden sets rather than being a theoretical edge. `suffixed()` appends to the `OsString` instead. The test asserts the correct name AND asserts that it differs from `with_extension`'s, so a future "simplify this" is caught rather than silently reintroducing it. TWO SWALLOWED ERRORS `sha256_hex` discarded a `fmt::Result` through `let _ = write!`. It cannot fail into a `String`, but the project's rule is about the shape, not the odds, and the hex is now built by hand with no fallible call to discard. Review also suggested `format!("{:x}", Sha256::digest(data))`. That does not compile: sha2 0.11 returns `hybrid_array::Array<u8, _>`, which does not implement `LowerHex` -- `the trait bound Array<u8, ...>: LowerHex is not satisfied`, checked against a scratch crate rather than assumed. The digests are now pinned against the known SHA-256 of `""` and `"abc"`, which is an independent oracle rather than our own output. The C ABI returned a flat `-3` for every write failure. A full disk, a read-only directory and a permission denial were indistinguishable to the testbench -- and the testbench is the only thing that sees this, so discarding the cause at the boundary is the one place it cannot be recovered later. Codes at or below `-100` are now `-(100 + errno)`, with `-1`..`-9` still meaning "the call was malformed" and `-3` reserved for an error carrying no `raw_os_error`. STILL OPEN, DELIBERATELY NOT FIXED HERE The fourth finding -- workspace feature unification -- is REAL and confirmed. A `cargo build --workspace` builds `rustynes-core` ONCE with the union of features, and that union now includes `cpu-boot-trace` and `irq-timing-trace`: ['cpu-boot-trace', 'debug-hooks', 'default', 'hd-pack', 'irq-timing-trace', 'std'] measured through `--message-format=json` rather than inferred. It does not affect the shipped binary (`-p rustynes-frontend` never selects this crate) and it does not affect behaviour (the features are output-only; AccuracyCoin is 141/141 in a workspace run), but "the emulation core is untouched" is imprecise as written for workspace-wide builds. Every fix for it trades away something real -- excluding the crate from the workspace costs `--workspace` test coverage of its 14 tests, and making the trace features non-default costs the compile-time guarantee that a misconfigured build cannot export empty goldens. That is a maintainer's call about which guarantee matters more, not one to make silently inside a review round, so it is carried rather than guessed at. Gates: fmt, workspace clippy, the four frontend feature combos, both wasm32 combinations, the no_std thumbv7em build, rustdoc with warnings as errors, and 14 tests in the crate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(cosim): exclude the crate from the workspace so the accuracy battery tests the shipped scheduler `rustynes-cosim` enables `cpu-boot-trace` and `irq-timing-trace` on `rustynes-core`, and cargo unifies features across a workspace build. As a member, it made `cargo build --workspace` compile the core ONCE with the union. Measured through `--message-format=json` rather than inferred: before: ['cpu-boot-trace', 'debug-hooks', 'default', 'hd-pack', 'irq-timing-trace', 'std'] after: ['debug-hooks', 'default', 'hd-pack', 'std'] THE PERCENTAGE IS NOT THE REASON The instrumented build costs +1.24% / +1.39% / +1.89% across the three `full_frame` benches -- BELOW this project's own 3% adoption bar. Measured and recorded precisely because it shows performance was never the argument, and a reviewer's "severely degrading emulator performance" overstated it. The reason is that `irq-timing-trace` selects a DIFFERENT `for sub_dot in 0..3` loop in `Bus::tick_one_cpu_cycle` -- there are two, under opposite `cfg`s. So CI's `cargo test --workspace --release --features test-roms`, the accuracy battery, was validating a scheduler no user runs. That is the v2.3.4 defect repeating, where the coverage harness tested a load path no user runs, and it is wrong at any percentage. It does not affect the shipped binary: `cargo build --release -p rustynes-frontend` never selects this crate. Nor the perf gate, which is `cargo bench -p rustynes-core`. Exactly one gate was compromised, and it was the one that matters most. WHAT EXCLUSION COSTS, AND WHY NEITHER COST IS LEFT ON TRUST An excluded package cannot use `field.workspace = true`, so version, edition, rust-version, license, repository and both lint tables are duplicated. Duplication nothing checks is duplication that drifts -- and a crate quietly holding itself to weaker lints than the rest of the project is the kind of erosion nobody notices until it matters. `cosim_manifest_audit.rs` asserts every duplicated value still equals the workspace's, AND that the crate is still in `exclude` -- so re-adding it to `members` fails a test rather than silently restoring the unified build. Four mutations, all caught: re-added to members, a version drift, a weakened lint, an edition drift. `cargo fmt --all`, `clippy --workspace` and `test --workspace` no longer reach it, so CI gains three explicit steps naming its manifest. Without them the crate would simply stop being checked and nothing would say so. THE CLIPPY STEP EARNED ITS PLACE ON ITS FIRST RUN It reported a `must_use_candidate` on `Oracle::nes()` that `cargo clippy --workspace` had never surfaced. I do not have a confident explanation for why the standalone invocation sees it and the workspace one did not, and I would rather record that than invent one -- the useful fact is that the explicit step is not ceremony, it covers a real gap. VERIFIED The full gate matrix (fmt, workspace clippy, four frontend feature combos, both wasm32 combinations, no_std thumbv7em, rustdoc with warnings as errors), plus the excluded crate's own fmt / clippy / 14 tests, plus AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff on the SHIPPED path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#430) Promotes the accumulated CHANGELOG entries into a dated section, bumps the workspace version, and moves all 15 release anchors. Nothing else changes: no source file outside the two manifests is touched, so the shipped emulation core is the one that merged in #429. The release carries two versions, and that is not a naming convenience. v2.4.0 "Concordance" merged to main in #428 and was never tagged, so its entries were still sitting under [Unreleased] alongside v2.4.1's. Splitting them now would mean tagging v2.4.0 at a commit that main has already moved past, and publishing two releases minutes apart whose binaries are identical except for a version string. Instead the section states plainly that it carries both, and every entry belonging to the earlier one is marked (v2.4.0 item) so the attribution survives. Two entries belong to neither -- the release-anchor audit and the deferred-backlog sweep landed in #427, between the two -- and the preamble says so rather than letting "unmarked" silently mean v2.4.1. The version bump is deliberately part of this commit rather than an earlier one. Cargo.toml's own comment records why: release-auto.yml reads "workspace version has no matching tag" as "this is ready to release", so a bump that arrives early reaches main while the release does not exist and the workflow then fails closed on every push for want of release notes. Bumping here, with the CHANGELOG section and the release-notes override in the same change, is the only ordering that does not leave main red for a development window. Three mechanical consequences, each verified rather than assumed: - rustynes_libretro.info's display_version moves to v2.4.1, because libretro_info_audit.rs pins the LOCAL file against the workspace manifest. The cadence rule that lets display_version lag through a patch run applies to the UPSTREAM copy in libretro-super, which nothing here touches; the next upstream sync is still v2.5.0. - crates/rustynes-cosim/Cargo.toml carries its own version literal, because an excluded package cannot use `field.workspace = true`. cosim_manifest_audit.rs is what makes that duplication safe, and it is what would have caught this line being missed. - The CHANGELOG header is load-bearing, not decoration. release-auto.yml parses it for both the release-body fallback and the title codename, so the ` - <date> - "<Codename>" (<theme>)` shape is asserted by release_anchor_audit.rs rather than left to care. .github/release-notes/v2.4.1.md is a maintainer-authored override, so the published body is the narrative rather than the changelog. It discloses something the previous release's notes got wrong: v2.3.9's published body describes release_anchor_audit.rs, and `git cat-file -e v2.3.9:crates/rustynes-test-harness/tests/release_anchor_audit.rs` fails -- that work landed in #427, after the tag was cut. The audit exists because eight documents had reached six different answers about the current version; a release note describing work its own tag does not contain is the same failure one level up, so it is recorded here rather than quietly corrected. Anchors moved (15 across 10 documents, all named by the audit when it fails): README.md badge + Current Release, docs/STATUS.md, AGENTS.md x3 (the "What this is" block, the operating-notes bullet, and the never-claim-a-later-version guard), VERSION-PLAN.md header + its (current) table row, to-dos/ROADMAP.md, SUPPORT.md, SECURITY.md, ROADMAP.md x2, OVERVIEW.md x2, ARCHITECTURE.md. The VERSION-PLAN table gains a row for each of v2.4.0 and v2.4.1; the audit asserts exactly one row is marked (current), so a forgotten row fails loudly. Gates run on this tree, not inherited from the merge: fmt (workspace + the excluded crate), clippy --workspace --all-targets, the excluded crate's clippy, the four frontend feature combinations (scripting; scripting,hd-pack; retroachievements; full), BOTH wasm32 invocations, the no_std thumbv7em build, rustdoc with warnings as errors, cargo deny (advisories/bans/licenses/sources ok), actionlint, and markdownlint over every changed document. Tests: 126 workspace suites / 2222 passed / 0 failed, plus the excluded crate's 3 suites / 14 passed, which --workspace cannot reach. Accuracy re-run rather than reasoned about, since rustynes-core changed in both halves of what this tags: AccuracyCoin (RAM): pass rate = 100.00% over 141 assigned tests nestest_pc_c000_matches_golden_log ... ok The framebuffer decoder reports 120 and stays known-buggy; the RAM decoder is the authoritative one.
Delivers v2.4.0 "Concordance" items A–D. Every one traces to a recorded deferral; nothing here is invented for the release.
Item C — one atomic, durable file write (marquee)
The seven-property sequence v2.3.9 built for
Config::save_tois extracted intocrate::atomic_writeand adopted everywhere. The plan named three call sites; there were four.save_state.rsmatters most and the plan named it last: a truncated save state is a user's game progress, a worse loss than a truncated config, and it was still using the barefs::writethe config path had already been fixed for.per_game.rswas not in the plan at all, because it looks correct — sibling temp file, rename — so a sweep forfs::write-onto-a-target clears it. It held two of seven properties. Nofsync, so the rename could commit a directory entry pointing at bytes that never reached the medium; and a fixed scratch name shared across every process and concurrent call, which is the exact failure the mechanism exists to prevent, reintroduced by the mechanism itself. A partially-correct implementation is harder to spot than an absent one.The config path gains something it never had: a bounded retry past a transient Windows sharing violation, where
MoveFileExfails if another process has the target open. POSIX has no such constraint — which is why it went unnoticed, and why it would have surfaced as a Windows user reporting a save that failed for no visible reason.Two mutations forced design changes rather than confirming the design:
Ok(())— silently reporting a save that never happened — went uncaught.opts.mode(0o600)at creation already yields 0600 under any ordinary umask, so only a mode the umask masks distinguishes the two mechanisms.Two properties are not observable in-process and the module says so rather than letting a green suite imply coverage:
fsync(needs a power loss) and creation-mode (a race-window narrowing; a test can only see the end state).Item B — the timeline generation counter
v2.3.9 recorded that stale debug telemetry could not be cleared on a save-state load: of four ways the emulator jumps timeline, only one is reachable from a patchable frontend call site — wasm load-state restores inside a
spawn_localtask, and rewind happens entirely inside the core.restore_inner's existingclear_rewindparameter already draws the needed distinction, so the counter reuses it. This departs from the plan's enumeration deliberately: the plan listed netplay rollback as a bump site and stated the mechanism forbidding it — a same-timeline restore must not bump. Netplay rollback and run-ahead both go throughrestore_quietprecisely because they are same-timeline; bumping there would clear telemetry sixty times a second, worse than the defect being fixed.The counter is not serialized, and the plan asked for a
snapshot_schema_audit.rsentry — but that file audits the four chips, notNes. The property is pinned by an executable assertion instead: snapshot at generation N, advance past N, restore, assert it did not come back to N. Simulating serialization makes it fail with exactly that diagnostic.TimelineWatchis extracted becauseDebuggerOverlay::newneeds a window and a wgpu device, so nothing living only inside it is unit-testable — the same reasoning as item C's injectable predicate.Item D — two false byte-identity claims, now true
graphics.hd_packsandgraphics.shader_presetsboth documented a pre-feature config as "byte-identical". Both were, only until the first save. v2.3.9 corrected the prose and left the behaviour; this is that decision.Both directions tested, because the over-eager direction is the dangerous one: an
is_emptyreturningtrueunconditionally would silently discard saved presets on every save — data loss wearing the shape of a tidiness fix. A third test asserts the property for the field that does not exist yet, since the defect is precisely "a field was added and the save-side property was not considered".Item A — the upstream sync, filed
libretro-super#2074— one line,display_versionv2.3.5 → v2.3.9. Everything else was already correct upstream includinglicense = "GPLv3+". Verified before pushing that the branch file is now byte-identical to this repo's copy, which is whatlibretro_info_audit.rsexists to make possible.libretro/docs#1180needed nothing — it is a pull request open since 2026-08-16,MERGEABLE/CLEAN, unreviewed. I had called it an issue; the reusable lesson is thatgh api repos/OWNER/REPO/issues/Nreturns pull requests, because GitHub's issues endpoint serves both.AGENTS.mdgains the cadence rule: upstream PRs only on MINOR/MAJOR releases; next is v2.5.0; a licence change overrides and syncs immediately.Accuracy
rustynes-corechanges (item B), so the contract is verified, not asserted:Gates
Mutations, each confirmed to have actually run its named test: 6 for item B (including a simulated serialized counter), 4 for item D in both directions, 6 of 7 properties for item C with the two uncoverable ones documented.