Skip to content

RustyNES v2.7.1 — "Keepsake" (a save that appears to succeed now does)

Choose a tag to compare

@github-actions github-actions released this 23 Sep 16:27
8ca4de2

RustyNES v2.7.1 — "Keepsake"

The second release of the audit line that ADR 0041 put between v2.6.23 and the SuperStation One core (v3.0.0). It works from the core and frontend audits in docs/audits/, and its subject is the save a player trusts: a save that appears to succeed now does — in RetroArch. On the desktop and mobile frontends there is a larger gap this release found and does not close; see the last section.

No emulation path changes. The mapper edits only expose save memory the boards already held. Even so, AccuracyCoin 144/144 (RAM decoder, 0 fail, 0 not run) and nestest were re-run on the release tree: both green. The full --features test-roms workspace suite passes 2,630 tests, 0 failed on the release tree.

The MiSTer sibling does not change in this release, and no new bitstream is cut. No hardware has run any bitstream; the board session is v2.9.2.

Six boards handed RetroArch an empty battery save

The libretro core gives RetroArch the cartridge's battery RAM through Nes::sram(), and RetroArch writes it to the game's .srm file. The mapper trait's default is an empty slice. A board that kept its save memory in a field of its own, without overriding the pair, lost the player's save on every exit — and nothing said so: the game ran, the save appeared to succeed, and the file was empty.

Board Mappers Save memory Source
Bandai FCG 16, 159 24C01 / 24C02 serial EEPROM, 128 / 256 bytes, no CPU window core audit IMP-08
Taito X1-005 80 128 bytes at $7F00-$7FFF, unlocked by $A3 at both $7EF8 and $7EF9 IMP-09
TxSROM, TQROM 118, 119 the wrapped MMC3's PRG-RAM, which they did not forward to IMP-10
Multicart 15 15 PRG-RAM at $6000-$7FFF §5.1e
BMC-FK23C 176 (and 30 with CHR-ROM, which routes to 176/2) 32 KiB WRAM not in the audit

The sixth was found by the test, which does not trust a list of boards. battery_sram_exposed.rs builds every NES 2.0 mapper number, 0-4095, from an NES 2.0 image with the battery bit and 8 KiB of PRG-NVRAM, once with CHR-ROM and once with CHR-RAM. It writes a distinct marker across $6000-$7FFF and fails any board where the markers read back while sram() stays empty or unchanged. A new mapper that forgets the override now fails by default. Its reach has a limit worth stating: a blind write sweep finds writable RAM on 43 of the 296 images it builds. The rest either have no RAM or gate it behind a board-specific enable, and the test prints them rather than passing over them silently.

Two details the test had to get right, because each hid a failure:

  • One matching read proves nothing. A board that models open bus returns the address's high byte, which equals a marker about once in 256 addresses. The first version reported 133 false failures; the test now requires at least 64 surviving markers, which is well above chance and below the smallest real RAM (X1-005's 128 bytes).
  • Multicart 15 refuses CHR-ROM. A CHR-ROM-only loop never constructed it, so it passed silently. That is why every mapper is built both ways.

X1-005 (which needs its unlock sequence) and Bandai FCG (which has no CPU window at all) have their own tests, and each checks both directions: a write reaches sram(), and a restored sram_mut() is what the game then reads. Reverting each board's override is caught: 6 of 6.

User files are no longer truncated in place

std::fs::write truncates the file first and writes second, so a crash, a killed process or a full disk in between leaves a truncated file. v2.3.9 built an atomic writer and moved config, save states and cheats to it. The frontend audit (SEC-06) found the FDS disk save, RetroAchievements progress and movies still on the bare call. The real set was wider, and all of it now goes through write_atomic: the FDS disk save, RetroAchievements progress, movies, screenshots, subtitle and history-clip exports, fm2 / bk2 export, TAStudio projects, the memory-compare export, HD-pack builder output and the PPU viewer's CHR PNG export.

Recordings needed a different fix. ffmpeg runs with -y, which truncates its output first, so a failed or killed encode used to destroy a recording already saved under that name. It now encodes to a *.rustynes-partial.<ext> sibling, which is renamed over the target only when ffmpeg exits 0. If that final rename fails, for example on a locked destination, the finished encode is kept and its path is reported rather than deleted.

A new test fails on any fs::write, File::create or OpenOptions writer in the frontend outside an allow-list. Each entry names the file, the call, the exact number of calls it excuses, and the reason. The first version matched fs::write alone, and review showed that missed both of the writers above. Five exceptions remain: the atomic writer itself, the CPU trace dump to the OS temp directory, the raw capture temps a recording deletes after its mux, the perf-overlay diagnostic CSVs, and the header editor, which overwrites 16 bytes in place without truncating anything.

An unusable config.toml was replaced by defaults, and the next save overwrote the user's file (CON-04). It is now copied to config.toml.corrupt.bak first, and the outcome is logged either way. That covers a file that fails to parse and, after review, one that is not valid UTF-8: that case failed before the parser ran and had skipped the copy.

Three mapper files are recorded as derived

The core audit (§6.2) proposed deleting the Mesen2 / puNES citations in m085_vrc7.rs, m099_vs_system.rs and m244_cne_decathlon.rs. Those comments quote Mesen2 source expressions, which makes them statements of derivation, and deleting them would be laundering. The project's rule since the provenance correction is to attribute, never to scrub. So each file now carries a // Provenance: header naming the derived region, a row in docs/originality-and-provenance.md §1, and a NOTICE entry. The comments themselves are unchanged.

A new test, provenance_record_audit.rs, fails when a header and its §1 row disagree in either direction. Writing it corrected a documented command: the regeneration command in AGENTS.md used a glob that does not descend into subdirectories, and it undercounted the derived files as 24 when there are 26. The test checks that the record is consistent. It cannot check that the record is complete, and classifying code as derived remains a human judgement.

CI does less on every push, and a benchmark was measuring the wrong thing

Also in this release, from #547:

  • PR checks run only what a change can affect. A draft PR runs the fast gates. The heavy jobs (test, test-roms, the Android build and the Pages build) run once it is marked ready, which measured ~6 minutes of wall clock against 18-32 before. Android Gradle packaging runs on a PR only when android/** changed. The path filters are derived from cargo tree --target all, which found the Android workflow ignoring eight of the eleven crates it compiles.
  • Runners pinned to Ubuntu 26.04. The workflow logs are down to zero warnings where the source is ours: the Android toolchain, NDK and Kotlin warnings, 13 dead links in the MkDocs handbook (which now builds with --strict), and a Gradle configuration-cache failure the first fix attempt caused.
  • The stock full_frame benches had measured the fast dot path since v2.2.3, when that path became the PPU default and the stock benches never selected one. Each stock/_fast pair therefore timed the same routine. The benches now select the path explicitly, and docs/performance.md carries corrected figures: nestest 4.458 ms exact / 3.950 ms shipped (−11.4%), flowing_palette 2.672 / 2.654 ms (neutral).

Verification

Check Result
AccuracyCoin (RAM decoder) 144 / 144, 0 fail, 0 not run
nestest pass
--features test-roms workspace suite 2,630 passed, 0 failed
fmt, clippy (default, scripting,hd-pack, retroachievements, both wasm32), cargo doc -D warnings, no_std build clean
Mutations battery saves 6 / 6 plus the restore direction 1 / 1, truncating-writer guard 3 / 3 (bare write, reintroduced File::create, an extra call behind an argued entry), staged-encode publish 1 / 1, provenance record 3 / 3 — all caught

Ledgers: core-disposition.md (IMP-08/09/10, §5.1e, §6.2, new F-07) and frontend-disposition.md (SEC-06, CON-04).

What this does not fix

The desktop and mobile frontends do not persist cartridge battery RAM at all. This was found while checking the section above: nothing in rustynes-frontend or rustynes-mobile reads sram(), and the desktop app writes only the FDS disk sidecar. On those hosts, a game's in-cartridge save survives only inside a save state. The core audit had assumed the desktop frontend wrote .sav files; for mobile the frontend audit already records the gap (AND-09, MOB-05, planned for v2.7.4). The desktop gap is recorded in the frontend ledger and is not fixed here.

The new recording tests run with --features av-record, which CI lints but does not test. That was already true of the existing av_record tests.

Next: v2.7.2 closes the mapper gaps from the core audit (MMC5 multi-bank PRG-RAM, MMC1 SUROM/SXROM, Namco 163 nametable banking, open bus at $6000-$7FFF).