RustyNES v2.3.2 "Lucid" answers a question no NES emulator could answer before: why is this pixel this color? Click any pixel and get its whole causal chain — the PPU dot and scanline that emitted it, which layer won the priority decision, the nametable / attribute / pattern addresses of the tile actually on screen, the palette entry, and the CPU instruction and cycle that last wrote each of those bytes.
No emulation-core behavior changes. Everything is debug-hooks-gated, output-only, and default off, so AccuracyCoin holds at exactly 141/141 and nestest is 0-diff — verified, not asserted by construction.
The edge that was missing
RustyNES already shipped every ingredient except one, and the shape of the gap determined the design:
| existing tool | has | lacks |
|---|---|---|
| Trace Logger | PC, registers, cycle | any link to an effect |
| Event Viewer | the $2000-$3FFF write + its PPU position |
the PC, and the resolved destination |
| memory access counter | per-address counts, last-access cycle | the PC |
| HD-pack tile source | per-pixel tile context | write history |
Nothing recorded the edge from a byte in PPU memory back to the instruction that stored it. That is what this release adds.
Recording it needs both sides of the bus
Neither side knows enough alone. The bus has the program counter; the PPU never sees it. The PPU has the effective destination; the bus never sees it, because a STA $2007 lands in a nametable or in palette RAM depending on the PPU's internal v, and an OAM byte arrives either through $2004 or as one of 256 bytes of a DMA burst.
So the executing instruction's context is pushed down once per instruction — in the block that already performs the breakpoint check, so rustynes-cpu is untouched and no new bus hook was needed — and the store sites stamp it.
Three things the tests found that reading the code did not
- An OAM DMA burst is attributed to its trigger, not its victim.
STA $4014only arms the transfer; its 513 or 514 cycles (the count depends on cycle alignment) are stolen from the instructions that follow. The first implementation named theJMPthat happened to be running. All 256 bytes now name the store that actually caused them. - A tile is defined when its PATTERN is fetched, not when its nametable byte is read. The PPU performs two dummy nametable fetches at dots 337-340, which clobbered the pending tile and made pixels x=8..15 report the tile belonging to x=16..23.
$2006 = $20,$00leaves fine-Y at 2, not 0 — bits 12-14 of a VRAM address are the fine-Y field. The "wrong" pattern address that flagged this turned out to be right.
Deterministic replay attestation
A .rnm movie can now carry a rolling hash of its run that anyone else can independently re-derive (tamper-evident, not forgery-resistant — it is a 64-bit FNV-1a digest, so it catches accidental divergence and casual edits, not a motivated forger):
$ rustynes verify run.rnm --rom game.nes
VERIFIED: 150 frames reproduced exactly (hash 23248f1a4b49f4e6).Exit codes are distinct on purpose: 0 verified, 1 mismatch, 3 not attested — a movie that makes no claim has not failed.
Running it caught a real design error. The first implementation hashed the framebuffer alone, and an end-to-end tamper check confirmed a movie whose input log had been edited as genuine — because the ROM under test never reads the controller, so the video really was identical. Output alone does not pin the input stream. Each frame now folds in the input and the video it produced, so the record states the honest claim: these inputs, applied to this ROM, produced this output.
A checkpoint every 64 frames localizes a mismatch to a 64-frame window rather than reporting only a verdict — a bit flipped at frame 100 reports "first divergence in frames 64..=127".
No format-version bump was needed. .rnm already had a precedent for additive trailing fields, so existing movies round-trip byte-for-byte unchanged and a pre-v2.3.2 reader parses an attested movie as a plain one.
Deliberately not done
- Hashing the core snapshot would detect more divergence, and was rejected: the snapshot schema is versioned and bumps between releases, so every bump would silently invalidate every previously-recorded attestation.
- Audio is not covered by attestation — the host drains samples as they are produced, so the core cannot see a whole run's audio. Stated rather than implied.
- Attestation is skipped while run-ahead is on, because run-ahead presents a frame ahead of the persistent timeline and such a record could never verify. If run-ahead is toggled on mid-recording the frame counts diverge and the tail is dropped at load: the failure mode is "no attestation", never "a wrong one".
- The egui 0.36 dependency bump is not in this release.
egui-winit0.36.1 cannot compile forwasm32-unknown-unknown(an upstreamcfgmismatch onDroppedFile::bytes), and RustyNES ships a wasm demo. The complete migration — which also requires wgpu 30 — is preserved on a branch, native-green, ready to rebase when upstream publishes a fix.
Also in this release
- The standing snapshot-schema audit caught all 13 new PPU fields on their first run, exactly as designed; each now carries the reason it is excluded rather than serialized.
- Dependency maintenance:
gradle/actions6.3.0,taiki-e/install-action2.85.10, and the clap 4.6.6 / clap_complete 4.6.9 group.
Verification
cargo test --workspace --features test-romsgreen with zero failures — AccuracyCoin 141/141 both directly and through run-ahead, nestest 0-diff,visual_regression.- Workspace clippy plus every frontend feature combo and both wasm targets clean at
-D warnings;cargo fmt --all --check;RUSTDOCFLAGS="-D warnings" cargo doc; thethumbv7em-none-eabihfno_stdcross-compile. - Replay attestation exercised end to end through the real CLI: a genuine run verifies, a one-bit input edit is caught with its checkpoint window.