Skip to main content

rustynes_core/
nes.rs

1//! `Nes` facade — the public entry point that owns the entire emulator.
2//!
3//! Per `docs/architecture.md` §Public API surface. Mirrors the surface
4//! that `rustynes-frontend` and `rustynes-test-harness` will consume.
5
6use alloc::vec::Vec;
7use alloc::{format, vec};
8use rustynes_cpu::Cpu;
9use rustynes_mappers::RomError;
10use rustynes_ppu::{PaletteInit, PpuRevision};
11use sha2::{Digest, Sha256};
12
13// `core::time::Duration` is identical to `std::time::Duration` (same Duration
14// type, re-exported through std for convenience). Using the `core` path keeps
15// the public API surface portable to `#![no_std]` consumers without changing
16// any caller. See `docs/architecture.md` §149 (no_std + alloc migration).
17use core::time::Duration;
18
19use crate::Cpu2A03Revision;
20use crate::Region;
21use crate::bus::SystemBus;
22use crate::controller::Buttons;
23use crate::debug::{ApuDebugView, CpuDebugView, MapperDebugView, PpuDebugView};
24use crate::genie::{GenieCode, GenieError};
25use crate::input_device::InputDevice;
26use crate::rewind::{REWIND_DEFAULT_KEYFRAME_PERIOD, REWIND_DEFAULT_MAX_BYTES, RewindRing};
27use crate::save_state::{self, ROM_HASH_TAG_LEN, SnapshotError};
28
29/// Nominal NTSC frame duration: `1 / 60.0988 Hz ≈ 16.6393 ms`.
30///
31/// Real hardware alternates 29780-cycle and 29781-cycle frames (the half
32/// cycle averages to 60.0988 Hz); for wall-clock pacing we treat the
33/// average as a single fixed-point interval and let small slips snap.
34pub const FRAME_DURATION_NTSC: Duration = Duration::from_nanos(16_639_267);
35
36/// Nominal PAL frame duration: `1 / 50.0070 Hz ≈ 19.9972 ms`.
37pub const FRAME_DURATION_PAL: Duration = Duration::from_nanos(19_997_200);
38
39/// Nominal Dendy frame duration: 50 Hz Russian famiclone, same as PAL.
40pub const FRAME_DURATION_DENDY: Duration = Duration::from_nanos(19_997_200);
41
42/// v2.1.7 P5 — power-on 2 KiB CPU work-RAM contents.
43///
44/// Real NES hardware powers up with unreliable RAM (nesdev "CPU power up
45/// state"); a few titles read uninitialized RAM before writing it (*Final
46/// Fantasy*'s RNG seed, *River City Ransom*, *Cybernoid*). This selects what
47/// pattern the work RAM (and the open-bus latch) is filled with at power-on.
48///
49/// **Default-off / deterministic.** [`Default`] ([`Self::Zeroed`]) is the
50/// established all-zero fill CI, the regression oracle, and save-state tests
51/// use; the other variants are opt-in and still fully **deterministic** (no
52/// wall-clock / OS RNG), so the `same config + ROM + input ⇒ bit-identical`
53/// contract holds.
54#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
55pub enum PowerOnRam {
56    /// Default. Work RAM + open bus power up all-zero (current behavior).
57    #[default]
58    Zeroed,
59    /// Deterministic `xorshift64` randomization keyed on the seed (the existing
60    /// developer mode; see [`Nes::from_rom_with_power_on_seed`]). Surfaces
61    /// software that depends on a particular post-power-on RAM pattern.
62    Seeded(u64),
63    /// Fill every work-RAM byte (and the open-bus latch) with a single uniform
64    /// byte — a documented known pattern (e.g. `0xFF`, the all-ones some
65    /// consoles come up with). Deterministic.
66    Filled(u8),
67}
68
69/// v2.1.7 P5 — power-on hardware configuration for a freshly-constructed or
70/// power-cycled machine.
71///
72/// A small, forward-extensible bundle of the "what state does the silicon come
73/// up in" knobs that are otherwise scattered. Currently just the work-RAM fill
74/// ([`PowerOnRam`]); the PPU-revision and power-up-palette knobs are exposed as
75/// their own setters on [`Nes`] since they live in the PPU. All fields default
76/// to the established behavior, so [`PowerOnConfig::default`] is byte-identical.
77#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
78pub struct PowerOnConfig {
79    /// Work-RAM power-on fill. Defaults to [`PowerOnRam::Zeroed`].
80    pub ram: PowerOnRam,
81}
82
83/// v2.9.8 — which console wires the PPU's `/RESET` line.
84///
85/// The 2C02 is the same chip in every NTSC console; what differs is the board
86/// around it. The `NESdev` "PPU power up state" page (§Famicom, and its closing
87/// note on front- and top-loaders) documents two wirings:
88///
89/// - **NES (front-loader, NES-001)** — the CPU and PPU are reset together. At
90///   power-on and on every press of Reset the PPU spends about 29,658 CPU
91///   cycles (NTSC) in its warm-up state, ignoring writes to `$2000`, `$2001`,
92///   `$2005` and `$2006`, and Reset clears PPUCTRL, PPUMASK, the scroll/address
93///   latch and the read buffer.
94/// - **Famicom** — the PPU's `/RESET` is tied to 5 V and only the CPU's rides
95///   a 0.47 µF capacitor. At power-on the PPU therefore starts initialising
96///   roughly one frame before the CPU leaves reset, which is longer than the
97///   warm-up window, so the CPU's first instructions can already write the
98///   masked registers. The Reset button reaches only the CPU: the PPU keeps
99///   running and keeps its register state.
100///
101/// What this models, and what it does not:
102///
103/// - Power-on under [`Self::Famicom`] closes the warm-up window before the
104///   first instruction ([`rustynes_ppu::Ppu::end_warmup`]). `NESdev` gives the
105///   lead as "approximately one frame ... the exact timing has not been
106///   measured, and may vary", so the PPU's frame position is left where the NES
107///   model puts it rather than advanced by a guessed amount.
108/// - A warm reset under [`Self::Famicom`] leaves the PPU untouched. The APU,
109///   DMA unit and cartridge reset exactly as under [`Self::Nes`].
110/// - The NES-101 top-loader shares the Famicom's reset wiring, but `NESdev` does
111///   not document its power-on lead, so it is not offered as a separate model.
112///
113/// **Off by default.** [`Self::Nes`] is the model every release before v2.9.8
114/// emulated, so the default build is byte-identical. The selection is a
115/// host/config knob, never derived from the ROM: NES 2.0 has no console type
116/// that distinguishes a Famicom from an NES, and the project does not guess a
117/// console from a per-game list. It is not part of the save-state (the warm-up
118/// counter it acts on already is), and like the other hardware knobs on [`Nes`]
119/// it is re-applied by the host after a load or power-cycle.
120#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
121pub enum ConsoleModel {
122    /// Default. Front-loading NES: the CPU and PPU share the reset line.
123    #[default]
124    Nes,
125    /// Famicom: the PPU is never held in reset, so it leaves its warm-up before
126    /// the CPU starts and ignores the Reset button.
127    Famicom,
128}
129
130/// v1.1.0 beta.2 (Workstream C, T-110-C2) — one cycle-trace record.
131///
132/// The CPU register file + cycle count captured just before an instruction
133/// executes. Recorded by [`Nes::run_frame`] while tracing is enabled (the
134/// `debug-hooks` feature). The frontend disassembles the instruction at `pc`.
135#[cfg(feature = "debug-hooks")]
136#[derive(Clone, Copy, Debug, Eq, PartialEq)]
137pub struct TraceRec {
138    /// Program counter at instruction fetch.
139    pub pc: u16,
140    /// Accumulator.
141    pub a: u8,
142    /// X index.
143    pub x: u8,
144    /// Y index.
145    pub y: u8,
146    /// Stack pointer.
147    pub s: u8,
148    /// Processor status bits.
149    pub p: u8,
150    /// CPU cycle count at fetch.
151    pub cycle: u64,
152}
153
154/// The largest extra-scanline overclock the core accepts.
155///
156/// [`Nes::set_extra_scanlines`] clamps to it, and a movie's options record
157/// above it is refused. 80 lines is the range the desktop's Settings field
158/// has offered since v2.9.7; the value moved here at v2.9.9 (NC-11) so every
159/// host and every file format meets the same bound.
160pub const MAX_EXTRA_SCANLINES: u16 = 80;
161
162/// The largest CPU-multiplier overclock the core accepts (v3.1.0,
163/// `T-CPU-OVERCLOCK`).
164///
165/// [`Nes::set_cpu_overclock`] clamps to `1..=MAX_CPU_OVERCLOCK`, and a movie's
166/// options record outside that range is refused. The bound is the master
167/// clock: the CPU divider must stay at least 3 for the read / write split of
168/// a cycle (NTSC 12 / 4 = 3), and `x4` is already four times the CPU time a
169/// frame has.
170pub const MAX_CPU_OVERCLOCK: u8 = 4;
171
172/// Top-level NES emulator handle.
173///
174/// Owns the CPU, PPU, mapper, RAM, and controller stub. Construct via
175/// [`Nes::from_rom`]; drive forward via [`Nes::run_frame`] or
176/// [`Nes::step_instruction`]. The framebuffer can be sampled at any time via
177/// [`Nes::framebuffer`].
178// Several independent debug-hooks toggles (breakpoints / trace / exec log) push
179// the bool count over clippy's threshold; they are genuinely independent flags.
180#[allow(clippy::struct_excessive_bools)]
181pub struct Nes {
182    cpu: Cpu,
183    bus: SystemBus,
184    /// The ROM's persistent identity: SHA-256 of an iNES / NES 2.0 image's
185    /// bytes AFTER its 16-byte header (trainer, PRG, CHR, anything trailing),
186    /// or of the whole image for anything without the `NES\x1A` magic (FDS
187    /// disks, NSF files). See [`Nes::rom_sha256`] for why the header is left
188    /// out.
189    rom_sha256: [u8; 32],
190    /// SHA-256 of the complete image as constructed, header included. Only the
191    /// Vs. System database consults it, as the fallback to its identity key
192    /// ([`Nes::image_sha256`]).
193    image_sha256: [u8; 32],
194    /// Optional rewind ring buffer. Disabled by default — frontend opts in
195    /// via [`Nes::enable_rewind`].
196    rewind: Option<RewindRing>,
197    /// v2.8.0 Phase 3 — when `false`, [`Nes::run_frame`] skips the rewind
198    /// capture even with the ring armed. Run-ahead sets this for its
199    /// hidden + visible frames so only the persistent timeline's frames
200    /// land in the ring. Default `true` (byte-identical legacy behavior).
201    rewind_capture_enabled: bool,
202    /// v2.8.0 Phase 3 — reused scratch for the per-frame rewind capture
203    /// (kills the ~320 KiB snapshot allocation per frame).
204    rewind_snap_buf: Vec<u8>,
205    /// v2.9.0 (re-audit NC-03) — reused scratch for the rollback backup every
206    /// restore takes before it mutates anything (see `restore_inner`). Pooled
207    /// because the quiet path runs once per frame under run-ahead.
208    restore_backup: Vec<u8>,
209    /// Optional per-CPU-instruction boot trace (Session-12 observability).
210    /// Gated on the `cpu-boot-trace` cargo feature so the default build
211    /// pays no memory or codegen cost. See
212    /// `crates/rustynes-core/src/cpu_boot_trace.rs`.
213    #[cfg(feature = "cpu-boot-trace")]
214    cpu_boot_trace: Option<crate::cpu_boot_trace::CpuBootTrace>,
215    /// v1.1.0 beta.2 (Workstream C) — exec/PC breakpoints checked in
216    /// [`Nes::run_frame`]. Gated on `debug-hooks` so the default build's hot
217    /// path is untouched. Output-only: a hit stops the frame early and records
218    /// the PC; it never mutates emulation, so determinism holds.
219    #[cfg(feature = "debug-hooks")]
220    breakpoints: Vec<u16>,
221    /// Whether breakpoints are armed (lets the UI keep its list but pause
222    /// checking). Default `true`.
223    #[cfg(feature = "debug-hooks")]
224    breakpoints_enabled: bool,
225    /// The PC that last hit a breakpoint, taken by the frontend to pause.
226    #[cfg(feature = "debug-hooks")]
227    break_hit: Option<u16>,
228    /// The PC to skip the breakpoint check on for the next step, so a
229    /// "continue" resumes *past* the instruction it stopped on instead of
230    /// re-breaking. Unlike a blind "skip the first iteration", this only skips
231    /// the exact resumed PC — so a breakpoint sitting at the frame's start PC
232    /// (after a reset / save-state load / manual PC change) still fires.
233    #[cfg(feature = "debug-hooks")]
234    skip_breakpoint_at: Option<u16>,
235    /// v1.1.0 beta.2 (T-110-C2) — cycle-trace ring buffer (most-recent
236    /// [`Self::TRACE_CAP`] instructions). Recorded in `run_frame` while
237    /// `trace_enabled`. Output-only.
238    #[cfg(feature = "debug-hooks")]
239    trace: alloc::collections::VecDeque<TraceRec>,
240    /// Whether the cycle-trace logger is recording. Default `false`.
241    #[cfg(feature = "debug-hooks")]
242    trace_enabled: bool,
243    /// v1.1.0 beta.3 (T-110-E2) — per-frame executed-PC log for the Lua
244    /// `onExec` callback. Distinct from [`Self::trace`] (a 50k rolling buffer
245    /// shared with the Trace Logger panel): this is **cleared every frame**, so
246    /// `onExec` replays only this frame's PCs — no stale / duplicate dispatch.
247    /// Output-only; recorded only while `exec_logging`.
248    #[cfg(feature = "debug-hooks")]
249    exec_log: Vec<u16>,
250    /// Whether the per-frame exec-PC log is recording. Default `false`.
251    #[cfg(feature = "debug-hooks")]
252    exec_logging: bool,
253    /// v2.4.0 item B — a monotonic marker that changes whenever this `Nes`
254    /// jumps to a different point on its timeline.
255    ///
256    /// **Session-local, and deliberately NOT serialized.** The counter's only job
257    /// is to be *different* after a discontinuity. Serializing it would put an
258    /// OLD value back on restore, so loading a state saved earlier in the same
259    /// session could hand a consumer a generation it has already seen — and the
260    /// consumer would conclude nothing jumped at the exact moment something did.
261    /// A session-local monotonic counter cannot do that: it only ever increases,
262    /// so any restore produces a value no consumer has seen.
263    ///
264    /// It exists because the alternative — each consumer remembering the last
265    /// `cycle()` it saw and noticing a non-monotonic step — cannot see a restore
266    /// to a LATER state. The counter can, and needs no cooperation from any call
267    /// site, which matters because two of the four timeline jumps (wasm
268    /// load-state, and rewind) are not reachable from a patchable frontend call
269    /// site at all.
270    ///
271    /// # It is NOT part of the save state, deliberately
272    ///
273    /// The counter is session-local: it is not written by `snapshot`, not read by
274    /// `restore`, and a loaded state does not carry its own value across. What a
275    /// restore does instead is **increment the live counter**, which is the
276    /// correct reading of the event — "the timeline you were on has been replaced"
277    /// — and is true regardless of which state was loaded.
278    ///
279    /// Serializing it would be actively wrong in two ways. Loading the same slot
280    /// twice would restore the same generation twice, so a consumer comparing
281    /// against its last-seen value would miss the second load entirely. And a
282    /// value from another session says nothing about this one: the counter is only
283    /// ever meaningful as a comparison against the previous value *this process*
284    /// observed, which is why `timeline_generation()` documents that comparing it
285    /// across two `Nes` instances is meaningless.
286    ///
287    /// A consequence worth stating: because it lives outside the snapshot, the
288    /// `snapshot_schema_audit` test cannot see it, so nothing mechanical will
289    /// notice if this reasoning is ever invalidated.
290    timeline_generation: u64,
291}
292
293impl Nes {
294    /// The current timeline generation — see the field's documentation.
295    ///
296    /// A consumer holds the last value it saw and clears itself when this
297    /// differs. It is meaningless to compare across two different `Nes`
298    /// instances: a fresh one starts at zero, which is why ROM changes are
299    /// handled by their own hook (`DebuggerOverlay::clear_rom_bound_analysis`)
300    /// rather than by this counter.
301    #[must_use]
302    pub const fn timeline_generation(&self) -> u64 {
303        self.timeline_generation
304    }
305
306    /// Returns a reference to the internal WRAM.
307    pub fn wram(&self) -> &[u8] {
308        self.bus.ram.as_ref()
309    }
310
311    /// Returns a mutable reference to the internal WRAM.
312    pub fn wram_mut(&mut self) -> &mut [u8] {
313        self.bus.ram.as_mut()
314    }
315
316    /// Returns a reference to the cartridge SRAM (if any).
317    pub fn sram(&self) -> &[u8] {
318        self.bus.mapper.sram()
319    }
320
321    /// Returns a mutable reference to the cartridge SRAM (if any).
322    pub fn sram_mut(&mut self) -> &mut [u8] {
323        self.bus.mapper.sram_mut()
324    }
325
326    /// v2.9.6 — the cartridge's non-volatile data, what a battery save
327    /// persists: [`Self::sram`] on almost every board, and the flash image on
328    /// a self-flashable one (GTROM, a flashable UNROM 512), which has no RAM
329    /// at `$6000`. Hosts that persist saves read and restore THIS, gated on
330    /// [`Self::has_battery`]. `sram()` stays the `$6000` RAM that memory maps
331    /// describe. See `Mapper::save_data`.
332    pub fn save_data(&self) -> &[u8] {
333        self.bus.mapper.save_data()
334    }
335
336    /// Mutable [`Self::save_data`], for loading a save.
337    pub fn save_data_mut(&mut self) -> &mut [u8] {
338        self.bus.mapper.save_data_mut()
339    }
340
341    /// v2.9.6 — return the save data to a never-saved cartridge: zeroed RAM,
342    /// or the flash image as loaded.
343    pub fn clear_save_data(&mut self) {
344        self.bus.mapper.clear_save_data();
345    }
346
347    /// v2.7.3 — whether the cartridge has non-volatile save data: the header
348    /// declares battery-backed PRG-RAM (iNES flags 6 bit 1), or (v2.9.6) the
349    /// board is self-flashable and its flash is the save (GTROM, mapper 111,
350    /// and flashable UNROM 512, mapper 30, whose headers need not set the
351    /// bit). Persist [`Self::save_data`] when this is `true`.
352    ///
353    /// [`Self::sram`] is not the same question: several boards expose their
354    /// work RAM through it whatever the header says (NROM returns its 8 KiB
355    /// PRG-RAM for every image, and MMC1 and MMC3 allocate RAM by default). A
356    /// host that persists save RAM to disk — the desktop frontend's `.sav`
357    /// files — must gate on this, or a volatile cartridge acquires a save it
358    /// never had and restores stale RAM across power cycles. Always `false`
359    /// for an FDS disk (which has its own writable-disk save) and an NSF.
360    #[must_use]
361    pub const fn has_battery(&self) -> bool {
362        self.bus.cart.has_battery
363    }
364
365    /// Returns a reference to the internal VRAM (nametables).
366    pub fn vram(&self) -> &[u8] {
367        self.bus.ppu.vram_ref()
368    }
369
370    /// Returns a mutable reference to the internal VRAM (nametables).
371    pub fn vram_mut(&mut self) -> &mut [u8] {
372        self.bus.ppu.vram_mut()
373    }
374
375    /// Build a new emulator from raw ROM bytes (iNES 1.0 or NES 2.0).
376    ///
377    /// # Errors
378    ///
379    /// Returns the underlying [`RomError`] if the bytes don't parse.
380    pub fn from_rom(bytes: &[u8]) -> Result<Self, RomError> {
381        let mut bus = SystemBus::new(bytes)?;
382        // Cold-boot path: `Cpu::power_on()` seeds `S=$00`; the subsequent
383        // `reset()`'s `S -= 3` (wrapping) lands at `$FD`, matching Mesen2's
384        // power-up state. See `docs/audit/session-13-cpu-boot-fix-2026-05-21.md`.
385        let mut cpu = Cpu::power_on();
386        cpu.reset(&mut bus);
387        Ok(Self {
388            cpu,
389            bus,
390            rom_sha256: rom_identity_sha256(bytes),
391            image_sha256: sha256_of(bytes),
392            rewind: None,
393            rewind_capture_enabled: true,
394            rewind_snap_buf: Vec::new(),
395            restore_backup: Vec::new(),
396            #[cfg(feature = "cpu-boot-trace")]
397            cpu_boot_trace: None,
398            #[cfg(feature = "debug-hooks")]
399            breakpoints: Vec::new(),
400            #[cfg(feature = "debug-hooks")]
401            breakpoints_enabled: true,
402            #[cfg(feature = "debug-hooks")]
403            break_hit: None,
404            #[cfg(feature = "debug-hooks")]
405            skip_breakpoint_at: None,
406            #[cfg(feature = "debug-hooks")]
407            trace: alloc::collections::VecDeque::new(),
408            #[cfg(feature = "debug-hooks")]
409            trace_enabled: false,
410            #[cfg(feature = "debug-hooks")]
411            exec_log: Vec::new(),
412            #[cfg(feature = "debug-hooks")]
413            exec_logging: false,
414            timeline_generation: 0,
415        })
416    }
417
418    /// Build an emulator with an explicit audio sample rate (the rate the
419    /// CPAL stream is opened at).
420    ///
421    /// # Errors
422    ///
423    /// Returns the underlying [`RomError`] if the bytes don't parse.
424    pub fn from_rom_with_sample_rate(bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
425        let mut bus = SystemBus::with_sample_rate(bytes, sample_rate)?;
426        // Cold-boot path: see comment in `from_rom`.
427        let mut cpu = Cpu::power_on();
428        cpu.reset(&mut bus);
429        Ok(Self {
430            cpu,
431            bus,
432            rom_sha256: rom_identity_sha256(bytes),
433            image_sha256: sha256_of(bytes),
434            rewind: None,
435            rewind_capture_enabled: true,
436            rewind_snap_buf: Vec::new(),
437            restore_backup: Vec::new(),
438            #[cfg(feature = "cpu-boot-trace")]
439            cpu_boot_trace: None,
440            #[cfg(feature = "debug-hooks")]
441            breakpoints: Vec::new(),
442            #[cfg(feature = "debug-hooks")]
443            breakpoints_enabled: true,
444            #[cfg(feature = "debug-hooks")]
445            break_hit: None,
446            #[cfg(feature = "debug-hooks")]
447            skip_breakpoint_at: None,
448            #[cfg(feature = "debug-hooks")]
449            trace: alloc::collections::VecDeque::new(),
450            #[cfg(feature = "debug-hooks")]
451            trace_enabled: false,
452            #[cfg(feature = "debug-hooks")]
453            exec_log: Vec::new(),
454            #[cfg(feature = "debug-hooks")]
455            exec_logging: false,
456            timeline_generation: 0,
457        })
458    }
459
460    /// Build an emulator from a Famicom Disk System `.fds` disk image and a
461    /// user-supplied 8 KiB BIOS (`disksys.rom`).
462    ///
463    /// The BIOS is never committed to this repo (it is Nintendo IP); the caller
464    /// supplies it (a frontend BIOS prompt is Stage 2). Construction parses the
465    /// disk container, builds the FDS device as the bus's mapper, and runs the
466    /// standard cold-boot reset (the BIOS reset vector at `$FFFC` drives the
467    /// disk-load sequence).
468    ///
469    /// Uses the default 44.1 kHz audio sample rate; use
470    /// [`Nes::from_disk_with_sample_rate`] to pick the rate.
471    ///
472    /// # Errors
473    ///
474    /// Returns the underlying [`RomError`] if the disk image is unparseable or
475    /// the BIOS is not exactly 8 KiB.
476    pub fn from_disk(disk_bytes: &[u8], bios_bytes: &[u8]) -> Result<Self, RomError> {
477        Self::from_disk_with_sample_rate(disk_bytes, bios_bytes, crate::bus::DEFAULT_SAMPLE_RATE)
478    }
479
480    /// Build an FDS emulator with an explicit audio sample rate. See
481    /// [`Nes::from_disk`].
482    ///
483    /// The reported `rom_sha256` hashes the disk-image bytes (not the BIOS), so
484    /// save-states / movies key off the disk the way cartridge builds key off
485    /// the ROM. A host booting a SAVED (game-written) copy of the disk passes
486    /// the pristine image's hash to [`Nes::set_rom_identity`] afterwards, so
487    /// the identity does not move with the game's disk saves (v2.9.9, NF-17).
488    ///
489    /// # Errors
490    ///
491    /// Returns the underlying [`RomError`] if the disk image is unparseable or
492    /// the BIOS is not exactly 8 KiB.
493    pub fn from_disk_with_sample_rate(
494        disk_bytes: &[u8],
495        bios_bytes: &[u8],
496        sample_rate: u32,
497    ) -> Result<Self, RomError> {
498        let mut bus = SystemBus::with_disk(disk_bytes, bios_bytes, sample_rate)?;
499        // Cold-boot path: see comment in `from_rom`.
500        let mut cpu = Cpu::power_on();
501        cpu.reset(&mut bus);
502        Ok(Self {
503            cpu,
504            bus,
505            rom_sha256: sha256_of(disk_bytes),
506            image_sha256: sha256_of(disk_bytes),
507            rewind: None,
508            rewind_capture_enabled: true,
509            rewind_snap_buf: Vec::new(),
510            restore_backup: Vec::new(),
511            #[cfg(feature = "cpu-boot-trace")]
512            cpu_boot_trace: None,
513            #[cfg(feature = "debug-hooks")]
514            breakpoints: Vec::new(),
515            #[cfg(feature = "debug-hooks")]
516            breakpoints_enabled: true,
517            #[cfg(feature = "debug-hooks")]
518            break_hit: None,
519            #[cfg(feature = "debug-hooks")]
520            skip_breakpoint_at: None,
521            #[cfg(feature = "debug-hooks")]
522            trace: alloc::collections::VecDeque::new(),
523            #[cfg(feature = "debug-hooks")]
524            trace_enabled: false,
525            #[cfg(feature = "debug-hooks")]
526            exec_log: Vec::new(),
527            #[cfg(feature = "debug-hooks")]
528            exec_logging: false,
529            timeline_generation: 0,
530        })
531    }
532
533    /// Build an emulator that plays an NSF-family music file: the classic
534    /// `NESM\x1a` container or the chunked `NSFE` one, told apart by their magic
535    /// in [`rustynes_mappers::parse_nsf`]. Expansion-chip audio declared in the
536    /// header (VRC6, VRC7, FDS, MMC5, N163, 5B) is synthesized by the same cores
537    /// the cartridge boards use. (Until v2.9.3 this comment said `NSFe` and
538    /// expansion audio were deferred, which stopped being true in v2.1.x.)
539    ///
540    /// NSF files carry a ripped NES sound engine plus an `init`/`play` address
541    /// pair, not a PPU program. Construction parses the file, installs a
542    /// [`rustynes_mappers::NsfMapper`] (a synthetic 6502 driver + the program
543    /// image) as the bus's mapper, and runs the standard cold-boot reset — the
544    /// driver's reset vector calls `init` for the starting song. At the standard
545    /// 60 Hz rate the driver enables vblank NMI and the NMI calls `play` once per
546    /// frame; at any other header rate a mapper cycle-timer IRQ calls it
547    /// instead (see `docs/mappers.md`). Audio is produced through the unchanged
548    /// lockstep loop; there is no video.
549    ///
550    /// Uses the default 44.1 kHz sample rate; see
551    /// [`Nes::from_nsf_with_sample_rate`].
552    ///
553    /// # Errors
554    ///
555    /// Returns the underlying [`RomError`] when the NSF header is malformed.
556    pub fn from_nsf(nsf_bytes: &[u8]) -> Result<Self, RomError> {
557        Self::from_nsf_with_sample_rate(nsf_bytes, crate::bus::DEFAULT_SAMPLE_RATE)
558    }
559
560    /// Build an NSF player with an explicit audio sample rate. See
561    /// [`Nes::from_nsf`].
562    ///
563    /// # Errors
564    ///
565    /// Returns the underlying [`RomError`] when the NSF header is malformed.
566    pub fn from_nsf_with_sample_rate(nsf_bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
567        let mut bus = SystemBus::with_nsf(nsf_bytes, sample_rate)?;
568        let mut cpu = Cpu::power_on();
569        cpu.reset(&mut bus);
570        Ok(Self {
571            cpu,
572            bus,
573            rom_sha256: sha256_of(nsf_bytes),
574            image_sha256: sha256_of(nsf_bytes),
575            rewind: None,
576            rewind_capture_enabled: true,
577            rewind_snap_buf: Vec::new(),
578            restore_backup: Vec::new(),
579            #[cfg(feature = "cpu-boot-trace")]
580            cpu_boot_trace: None,
581            #[cfg(feature = "debug-hooks")]
582            breakpoints: Vec::new(),
583            #[cfg(feature = "debug-hooks")]
584            breakpoints_enabled: true,
585            #[cfg(feature = "debug-hooks")]
586            break_hit: None,
587            #[cfg(feature = "debug-hooks")]
588            skip_breakpoint_at: None,
589            #[cfg(feature = "debug-hooks")]
590            trace: alloc::collections::VecDeque::new(),
591            #[cfg(feature = "debug-hooks")]
592            trace_enabled: false,
593            #[cfg(feature = "debug-hooks")]
594            exec_log: Vec::new(),
595            #[cfg(feature = "debug-hooks")]
596            exec_logging: false,
597            timeline_generation: 0,
598        })
599    }
600
601    /// Number of selectable songs in the loaded NSF (0 for a cartridge / disk).
602    #[must_use]
603    pub fn nsf_song_count(&self) -> u8 {
604        self.bus.nsf_song_count()
605    }
606
607    /// The currently-selected 0-based NSF song (0 for a cartridge / disk).
608    #[must_use]
609    pub fn nsf_current_song(&self) -> u8 {
610        self.bus.nsf_current_song()
611    }
612
613    /// Select a 0-based NSF song and restart playback on it (re-runs `init` via
614    /// a warm reset). No-op for a cartridge / disk.
615    pub fn nsf_set_song(&mut self, song: u8) {
616        if self.bus.nsf_set_song(song) {
617            // Re-vector through the driver's reset entry so `init` runs for the
618            // new track. Warm reset preserves the freshly-patched driver state.
619            self.reset();
620        }
621    }
622
623    /// Build an emulator with a **randomized power-on RAM** state (developer
624    /// mode; Phase 7 / T-72-005).
625    ///
626    /// Identical to [`Nes::from_rom`] except the 2 KiB CPU work RAM and the
627    /// open-bus latch are filled from a deterministic `xorshift64` PRNG keyed
628    /// on `seed`, modelling the unreliable power-on RAM of real hardware
629    /// (nesdev "CPU power up state"). Use this to shake out game/test code
630    /// that depends on a particular post-power-on RAM pattern.
631    ///
632    /// The randomization is **seeded and deterministic** — the same `seed`
633    /// yields the same state, so the `same seed + ROM + input ⇒ bit-identical`
634    /// contract still holds. The default [`Nes::from_rom`] (zeroed RAM) is
635    /// what CI, the regression oracle, and save-state tests use.
636    ///
637    /// # Errors
638    ///
639    /// Returns the underlying [`RomError`] if the bytes don't parse.
640    pub fn from_rom_with_power_on_seed(bytes: &[u8], seed: u64) -> Result<Self, RomError> {
641        Self::from_rom_with_power_on_config(
642            bytes,
643            PowerOnConfig {
644                ram: PowerOnRam::Seeded(seed),
645            },
646        )
647    }
648
649    /// v2.1.7 P5 — build an emulator with an explicit [`PowerOnConfig`].
650    ///
651    /// Generalizes [`Nes::from_rom_with_power_on_seed`]: the caller chooses the
652    /// power-on work-RAM fill ([`PowerOnRam::Zeroed`] / [`PowerOnRam::Seeded`] /
653    /// [`PowerOnRam::Filled`]). The config is stored on the bus so a subsequent
654    /// power-cycle re-applies the same fill (keeping `power_cycle == fresh
655    /// boot`). All fills are **deterministic**, so the `same config + ROM + input
656    /// ⇒ bit-identical` contract still holds. [`PowerOnConfig::default`]
657    /// ([`PowerOnRam::Zeroed`]) is byte-identical to [`Nes::from_rom`].
658    ///
659    /// # Errors
660    ///
661    /// Returns the underlying [`RomError`] if the bytes don't parse.
662    pub fn from_rom_with_power_on_config(
663        bytes: &[u8],
664        config: PowerOnConfig,
665    ) -> Result<Self, RomError> {
666        let mut nes = Self::from_rom(bytes)?;
667        // RAM is not consulted during the reset sequence (only the $FFFC/D
668        // vector is), so applying the fill after construction is correct.
669        nes.bus.set_power_on_ram(config.ram);
670        Ok(nes)
671    }
672
673    /// Reset (warm boot). Preserves WRAM; reloads PC from `$FFFC/D`.
674    pub fn reset(&mut self) {
675        // v2.4.0 item B — a warm reset lands somewhere else on the timeline, so a
676        // reconstructed call stack and the access counters describe a run that no
677        // longer exists.
678        self.timeline_generation = self.timeline_generation.wrapping_add(1);
679        self.bus.reset();
680        self.cpu.reset(&mut self.bus);
681    }
682
683    /// Power-cycle (cold boot). Zeroes WRAM, re-rolls phase, reloads vectors.
684    ///
685    /// A cold boot of the CONSOLE, not of its configuration. The PPU and the
686    /// APU are rebuilt to their power-on state, and since v2.9.8 every host
687    /// setting stored on them survives: the custom or generated palette
688    /// ([`Self::set_custom_palette`]), the overclock scanlines
689    /// ([`Self::set_extra_scanlines`]), the fast dot path
690    /// ([`Self::set_fast_dotloop`]), the OAM-decay model
691    /// ([`Self::set_oam_decay`]), the APU channel mask, per-channel gain and
692    /// filter model, and the armed state of the provenance stores (emptied
693    /// below). The settings stored on the bus survived already: the die
694    /// revisions, the console model, the Vs. DIP switches and PPU type, the
695    /// mirroring override and the Four Score. The power-on FILLS
696    /// ([`Self::set_power_on_ram`], [`Self::set_power_up_palette`]) are
697    /// re-applied, as a fresh boot applies them. What a cold boot does clear
698    /// is console state: RAM, registers, the mapper (battery RAM excepted),
699    /// the controllers' latches, and any non-standard input device, which the
700    /// host re-attaches. Until v2.9.8 the PPU and APU settings reverted to
701    /// their defaults, and each host had to re-push them.
702    pub fn power_cycle(&mut self) {
703        // v2.4.0 item B — see `reset`; a cold boot is the larger discontinuity.
704        self.timeline_generation = self.timeline_generation.wrapping_add(1);
705        self.bus.power_cycle();
706        // Cold-boot path: see comment in `from_rom`.
707        self.cpu = Cpu::power_on();
708        self.cpu.reset(&mut self.bus);
709        // v2.3.2 "Lucid" — a cold boot ends the history both provenance stores
710        // describe. Keep them armed (the user asked for them) but empty.
711        #[cfg(feature = "debug-hooks")]
712        {
713            self.bus.ppu.clear_write_attribution();
714            self.bus.ppu.clear_pixel_provenance();
715            // v2.3.7 — same for audio: a cold boot ends the history the
716            // register attribution describes.
717            self.bus.apu.clear_audio_provenance_history();
718        }
719    }
720
721    /// Run until the PPU finishes a frame. Returns the framebuffer slice.
722    ///
723    /// A CPU JAM ends the call early, as the 150,000-cycle budget does: the
724    /// returned framebuffer is whatever the PPU had drawn, and later calls
725    /// return at once until a reset. It does not panic (until v2.9.9 this
726    /// section said it did; NC-15). Check [`Self::cpu`]'s `is_jammed` to tell
727    /// a JAM from a completed frame.
728    pub fn run_frame(&mut self) -> &[u8] {
729        // Hard cap: at NTSC the frame budget is 29,780.5 CPU cycles. Run
730        // up to 5x that before bailing — gives breathing room for late
731        // VBL detection or DMA-stall heavy frames before declaring "stuck".
732        //
733        // v3.1.0: scaled by the CPU overclock, which puts up to
734        // MAX_CPU_OVERCLOCK times as many CPU cycles into one frame.
735        const MAX_CYCLES_PER_FRAME: u64 = 150_000;
736        let max_cycles = MAX_CYCLES_PER_FRAME * u64::from(self.bus.cpu_overclock());
737        let start = self.bus.cycle();
738        // v2.3.7 "Overtone" — anchor this frame's mix trace. The trace is
739        // per-frame (the index IS the cycle offset from here); the REGISTER
740        // attribution deliberately is not, because "which instruction last wrote
741        // $4003" has an answer that legitimately predates this frame.
742        #[cfg(feature = "debug-hooks")]
743        self.bus.apu.begin_audio_provenance_frame(start);
744        // T-110-C3 — the event viewer shows one frame; reset the log per frame.
745        #[cfg(feature = "debug-hooks")]
746        if self.bus.event_logging() {
747            self.bus.clear_events();
748        }
749        // T-110-E2 — the Lua onRead/onWrite access log is per-frame too.
750        #[cfg(feature = "debug-hooks")]
751        if self.bus.access_logging() {
752            self.bus.clear_accesses();
753        }
754        // T-110-E1 — the Lua onNmi/onIrq interrupt-service log is per-frame too
755        // (cleared here so a replay only ever sees this frame's services, never
756        // a stale carry-over — mirrors the exec_log clear below).
757        #[cfg(feature = "debug-hooks")]
758        if self.bus.interrupt_logging() {
759            self.bus.clear_interrupts();
760        }
761        // v1.6.0 Workstream A3 — reset the `TAStudio` lag-log "controller polled"
762        // flag so was_input_polled_this_frame() reflects only this frame (a
763        // frame that ends still-`false` is a lag frame). Output-only.
764        #[cfg(feature = "debug-hooks")]
765        self.bus.clear_controller_polled();
766        // v1.4.0 Workstream D (D2) — start each frame with no event-breakpoint
767        // hit so the frontend's "first hit of the frame" pause is per-frame.
768        #[cfg(feature = "debug-hooks")]
769        if self.bus.event_breakpoints() != 0 {
770            self.bus.clear_event_break_hit();
771        }
772        // T-110-E2 — the Lua onExec exec-PC log is per-frame (cleared here so a
773        // replay only ever sees this frame's PCs, never a stale carry-over).
774        #[cfg(feature = "debug-hooks")]
775        if self.exec_logging {
776            self.exec_log.clear();
777        }
778        while !self.bus.take_frame_complete() {
779            if self.cpu.is_jammed() {
780                break;
781            }
782            if self.bus.cycle().wrapping_sub(start) > max_cycles {
783                break;
784            }
785            #[cfg(feature = "debug-hooks")]
786            {
787                // v1.1.0 beta.2 (Workstream C) — exec/PC breakpoints. The
788                // `skip_breakpoint_at` PC is stepped past exactly once (so a
789                // "continue" resumes off the instruction it stopped on instead
790                // of re-breaking in place); any OTHER breakpoint PC — including
791                // one at the frame's starting PC after a reset / save-state load
792                // / manual PC change — still fires immediately.
793                if self.breakpoints_enabled && self.breakpoints.contains(&self.cpu.pc) {
794                    if self.skip_breakpoint_at == Some(self.cpu.pc) {
795                        self.skip_breakpoint_at = None;
796                    } else {
797                        // Hit: stop the (partial) frame and report the PC. No
798                        // state mutated; the frame simply isn't completed.
799                        self.break_hit = Some(self.cpu.pc);
800                        self.skip_breakpoint_at = Some(self.cpu.pc);
801                        return self.bus.framebuffer();
802                    }
803                } else {
804                    self.skip_breakpoint_at = None;
805                }
806                // T-110-E2 — per-frame exec-PC log for Lua onExec (bounded by
807                // MAX_CYCLES_PER_FRAME, so no explicit cap needed).
808                if self.exec_logging {
809                    self.exec_log.push(self.cpu.pc);
810                }
811                // v2.3.2 "Lucid" — push this instruction's `(pc, cycle)` down to
812                // the PPU so any CIRAM / OAM / palette byte it goes on to write
813                // is stamped with the instruction that caused it. Done here, in
814                // the existing per-instruction debug block, rather than through a
815                // new `CpuBus` hook: `run_frame` already holds both halves, so
816                // this costs two stores and leaves `rustynes-cpu` untouched.
817                //
818                // Unconditional rather than gated on the store being armed: the
819                // "is it armed?" question lives behind a `Box` on the other side
820                // of a crate boundary, so testing it would cost about as much as
821                // the two stores it would skip, in a block that already runs a
822                // breakpoint scan per instruction.
823                self.bus
824                    .ppu
825                    .set_attrib_context(self.cpu.pc, self.cpu.cycles);
826                // v2.3.7 "Overtone" — the same push-down for audio, so
827                // `Apu::write_register` can attribute a `$4000-$4017` write
828                // without `rustynes-cpu` knowing the feature exists. Same
829                // reasoning as the PPU line above: two unconditional stores are
830                // cheaper than testing an arm behind a `Box` across a crate
831                // boundary, in a block that already scans breakpoints.
832                self.bus
833                    .apu
834                    .set_attrib_context(self.cpu.pc, self.cpu.cycles);
835                // T-110-C2 — cycle trace: record the about-to-execute
836                // instruction's CPU state (ring-capped, oldest dropped).
837                if self.trace_enabled {
838                    if self.trace.len() >= Self::TRACE_CAP {
839                        self.trace.pop_front();
840                    }
841                    self.trace.push_back(TraceRec {
842                        pc: self.cpu.pc,
843                        a: self.cpu.a,
844                        x: self.cpu.x,
845                        y: self.cpu.y,
846                        s: self.cpu.s,
847                        p: self.cpu.p.bits(),
848                        cycle: self.cpu.cycles,
849                    });
850                }
851            }
852            #[cfg(feature = "cpu-boot-trace")]
853            self.cpu_boot_trace_record();
854            self.cpu.step(&mut self.bus);
855        }
856        // Sample any attached Zapper's light detection from the completed
857        // frame. This is a no-op (and the run loop above is byte-identical)
858        // when no Zapper is attached, so the determinism contract holds.
859        self.bus.sample_zapper_light();
860        // After the frame completes, push state into the rewind ring so
861        // the frontend's hold-F5 UX has somewhere to walk back from.
862        // v2.8.0 Phase 3 — run-ahead suppresses the capture for its hidden
863        // + visible frames via `set_rewind_capture(false)`.
864        if self.rewind.is_some() && self.rewind_capture_enabled {
865            self.rewind_capture();
866        }
867        self.bus.framebuffer()
868    }
869
870    /// v2.8.0 Phase 3 — enable/disable the per-frame rewind capture while
871    /// the ring stays armed. Run-ahead turns it off around its hidden +
872    /// visible frames so only persistent-timeline frames land in the ring.
873    /// Default `true`; with no rewind ring armed this is a no-op.
874    pub const fn set_rewind_capture(&mut self, enabled: bool) {
875        self.rewind_capture_enabled = enabled;
876    }
877
878    /// Whether the per-frame rewind capture is currently armed.
879    ///
880    /// Added in v2.3.6 so a caller that needs to suppress capture temporarily can
881    /// save and restore the *caller's* setting rather than assume the default.
882    /// `rustynes-probe` does exactly that around a trial: its replayed frames
883    /// never happened on the user's timeline, so they must not enter the ring —
884    /// but nor may re-enabling capture afterwards turn it on for someone who had
885    /// deliberately turned it off. Run-ahead predates this and still restores an
886    /// unconditional `true`, which is correct only because nothing else disables
887    /// capture today.
888    #[must_use]
889    pub const fn rewind_capture_enabled(&self) -> bool {
890        self.rewind_capture_enabled
891    }
892
893    /// Step exactly one CPU instruction. For debuggers / step-through tools.
894    pub fn step_instruction(&mut self) -> u8 {
895        #[cfg(feature = "cpu-boot-trace")]
896        self.cpu_boot_trace_record();
897        // v2.3.2 "Lucid" — mirror `run_frame`'s write-attribution context push,
898        // so single-stepping through a `$2007` store in the debugger attributes
899        // the byte to the stepped instruction and not to whatever `run_frame`
900        // last left latched.
901        #[cfg(feature = "debug-hooks")]
902        self.bus
903            .ppu
904            .set_attrib_context(self.cpu.pc, self.cpu.cycles);
905        #[cfg(feature = "debug-hooks")]
906        self.bus
907            .apu
908            .set_attrib_context(self.cpu.pc, self.cpu.cycles);
909        self.cpu.step(&mut self.bus)
910    }
911
912    /// v1.1.0 beta.2 (Workstream C) — add an exec/PC breakpoint at `addr`.
913    /// [`Nes::run_frame`] stops the frame the next time the program counter
914    /// reaches `addr` (reportable via [`Nes::take_break_hit`]). Idempotent.
915    /// `debug-hooks` only.
916    #[cfg(feature = "debug-hooks")]
917    pub fn add_breakpoint(&mut self, addr: u16) {
918        if !self.breakpoints.contains(&addr) {
919            self.breakpoints.push(addr);
920        }
921    }
922
923    /// Remove a previously-added exec breakpoint (no-op if absent).
924    #[cfg(feature = "debug-hooks")]
925    pub fn remove_breakpoint(&mut self, addr: u16) {
926        self.breakpoints.retain(|&a| a != addr);
927    }
928
929    /// Remove all breakpoints.
930    #[cfg(feature = "debug-hooks")]
931    pub fn clear_breakpoints(&mut self) {
932        self.breakpoints.clear();
933    }
934
935    /// The current exec breakpoints (insertion order).
936    #[cfg(feature = "debug-hooks")]
937    #[must_use]
938    // `Vec` -> slice deref coercion is not const, so this can't be `const fn`
939    // (clippy's `missing_const_for_fn` is a false positive here).
940    #[allow(clippy::missing_const_for_fn)]
941    pub fn breakpoints(&self) -> &[u16] {
942        &self.breakpoints
943    }
944
945    /// Arm/disarm breakpoint checking without discarding the list. Default on.
946    #[cfg(feature = "debug-hooks")]
947    pub const fn set_breakpoints_enabled(&mut self, enabled: bool) {
948        self.breakpoints_enabled = enabled;
949    }
950
951    /// Whether breakpoint checking is armed.
952    #[cfg(feature = "debug-hooks")]
953    #[must_use]
954    pub const fn breakpoints_enabled(&self) -> bool {
955        self.breakpoints_enabled
956    }
957
958    /// Take the PC that last hit a breakpoint (cleared on read). The frontend
959    /// polls this after [`Nes::run_frame`] to pause when a breakpoint fired.
960    #[cfg(feature = "debug-hooks")]
961    pub const fn take_break_hit(&mut self) -> Option<u16> {
962        self.break_hit.take()
963    }
964
965    /// v1.4.0 Workstream D (D2) — arm the event-driven breakpoint categories
966    /// (a bit-OR of [`crate::EventBpKind::bit`]). `0` (default) disarms every
967    /// category — the per-access taps are then a single cheap `mask == 0`
968    /// early-out. Output-only: a hit pauses + reports but never mutates state.
969    /// `debug-hooks` only.
970    #[cfg(feature = "debug-hooks")]
971    pub const fn set_event_breakpoints(&mut self, mask: u16) {
972        self.bus.set_event_breakpoints(mask);
973    }
974
975    /// The armed event-breakpoint category mask.
976    #[cfg(feature = "debug-hooks")]
977    #[must_use]
978    pub const fn event_breakpoints(&self) -> u16 {
979        self.bus.event_breakpoints()
980    }
981
982    /// Take the first event-breakpoint hit of the current frame (cleared on
983    /// read). The frontend polls this after [`Nes::run_frame`] to pause when an
984    /// armed hardware event fired, reporting its kind + frame/cycle/scanline/dot.
985    #[cfg(feature = "debug-hooks")]
986    pub const fn take_event_break_hit(&mut self) -> Option<crate::EventBreakHit> {
987        self.bus.take_event_break_hit()
988    }
989
990    /// Maximum cycle-trace ring depth (oldest records drop past this).
991    #[cfg(feature = "debug-hooks")]
992    pub const TRACE_CAP: usize = 50_000;
993
994    /// v1.1.0 beta.2 (T-110-C2) — start/stop the cycle-trace logger. While on,
995    /// each executed instruction's CPU state is pushed to a ring buffer (capped
996    /// at [`Self::TRACE_CAP`]). Default off.
997    #[cfg(feature = "debug-hooks")]
998    pub const fn set_trace_enabled(&mut self, enabled: bool) {
999        self.trace_enabled = enabled;
1000    }
1001
1002    /// Whether the cycle-trace logger is recording.
1003    #[cfg(feature = "debug-hooks")]
1004    #[must_use]
1005    pub const fn trace_enabled(&self) -> bool {
1006        self.trace_enabled
1007    }
1008
1009    /// Number of records currently in the trace ring.
1010    #[cfg(feature = "debug-hooks")]
1011    #[must_use]
1012    pub fn trace_len(&self) -> usize {
1013        self.trace.len()
1014    }
1015
1016    /// Clear the trace ring.
1017    #[cfg(feature = "debug-hooks")]
1018    pub fn clear_trace(&mut self) {
1019        self.trace.clear();
1020    }
1021
1022    /// Copy the trace ring oldest-first (for the trace panel / file export).
1023    #[cfg(feature = "debug-hooks")]
1024    #[must_use]
1025    pub fn trace_records(&self) -> alloc::vec::Vec<TraceRec> {
1026        self.trace.iter().copied().collect()
1027    }
1028
1029    /// Copy the most recent `n` trace records (oldest-first) — for the live
1030    /// trace panel's tail view, cheaper than [`Self::trace_records`] on a full
1031    /// ring.
1032    #[cfg(feature = "debug-hooks")]
1033    #[must_use]
1034    pub fn trace_tail_vec(&self, n: usize) -> alloc::vec::Vec<TraceRec> {
1035        let skip = self.trace.len().saturating_sub(n);
1036        self.trace.iter().skip(skip).copied().collect()
1037    }
1038
1039    /// v1.1.0 beta.2 (T-110-C3) — start/stop the event viewer. While on, the bus
1040    /// records this frame's PPU/APU/mapper writes (with their PPU position); the
1041    /// log is reset at each [`Self::run_frame`]. Default off; output-only.
1042    #[cfg(feature = "debug-hooks")]
1043    pub const fn set_event_logging(&mut self, enabled: bool) {
1044        self.bus.set_event_logging(enabled);
1045    }
1046
1047    /// Whether the event viewer is recording.
1048    #[cfg(feature = "debug-hooks")]
1049    #[must_use]
1050    pub const fn event_logging(&self) -> bool {
1051        self.bus.event_logging()
1052    }
1053
1054    /// The current frame's captured events (for the event-viewer panel).
1055    #[cfg(feature = "debug-hooks")]
1056    #[must_use]
1057    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
1058    pub fn events(&self) -> &[crate::bus::EventRec] {
1059        self.bus.events()
1060    }
1061
1062    /// v2.3.2 "Lucid" — arm or disarm per-byte **write attribution** for the
1063    /// PPU's own memories (CIRAM, OAM, palette RAM).
1064    ///
1065    /// While armed, every write to those memories is stamped with the program
1066    /// counter and CPU cycle of the instruction that performed it, which is the
1067    /// edge the pixel-provenance panel walks to answer "which instruction put
1068    /// this byte here?". The Event Viewer records the CPU-side `$2000-$3FFF`
1069    /// write and the memory-access counter records a cycle stamp, but neither
1070    /// carries the PC *and* the resolved destination — see
1071    /// [`rustynes_ppu::provenance`] for why the two halves are recorded on
1072    /// opposite sides of the bus.
1073    ///
1074    /// Default off. Arming allocates
1075    /// [`rustynes_ppu::WriteAttribution::HEAP_BYTES`]; disarming frees it.
1076    /// Output-only, so emulation is bit-identical either way.
1077    #[cfg(feature = "debug-hooks")]
1078    pub fn set_write_attribution(&mut self, enabled: bool) {
1079        self.bus.ppu.set_write_attribution(enabled);
1080    }
1081
1082    /// The write-attribution store, or `None` when not armed.
1083    #[cfg(feature = "debug-hooks")]
1084    #[must_use]
1085    pub fn write_attribution(&self) -> Option<&rustynes_ppu::WriteAttribution> {
1086        self.bus.ppu.write_attribution()
1087    }
1088
1089    /// Forget the current frame's per-pixel provenance, keeping it armed.
1090    ///
1091    /// Called automatically on power-cycle and on both restore paths; exposed so
1092    /// a host that rewinds by other means can do the same.
1093    #[cfg(feature = "debug-hooks")]
1094    pub fn clear_pixel_provenance(&mut self) {
1095        self.bus.ppu.clear_pixel_provenance();
1096    }
1097
1098    /// Forget every recorded write attribution, keeping the store armed.
1099    ///
1100    /// Call this after a save-state restore or a power-cycle: the restored bytes
1101    /// were not written by any instruction this session executed, and reporting
1102    /// the PCs that wrote those offsets before the restore would be a
1103    /// confidently wrong answer rather than an absent one.
1104    #[cfg(feature = "debug-hooks")]
1105    pub fn clear_write_attribution(&mut self) {
1106        self.bus.ppu.clear_write_attribution();
1107    }
1108
1109    /// v2.3.2 "Lucid" phase 2 — arm or disarm **per-pixel provenance**.
1110    ///
1111    /// While armed, every emitted pixel records the layer that won the priority
1112    /// decision, the exact `$3Fxx` palette address behind its color, and the
1113    /// nametable / attribute / pattern addresses of the tile **actually on
1114    /// screen** — which `v` cannot answer, because by display time it has
1115    /// advanced two tiles past the pixel.
1116    ///
1117    /// Composes with [`Self::set_write_attribution`]: provenance says which
1118    /// bytes produced a pixel, attribution says which instruction wrote them.
1119    /// Each is useful alone; together they are the full causal chain.
1120    ///
1121    /// Default off. Arming allocates
1122    /// [`rustynes_ppu::PixelProvenanceFrame::HEAP_BYTES`]. Output-only, so
1123    /// emulation is bit-identical either way.
1124    #[cfg(feature = "debug-hooks")]
1125    pub fn set_pixel_provenance(&mut self, enabled: bool) {
1126        self.bus.ppu.set_pixel_provenance(enabled);
1127    }
1128
1129    /// The current frame's per-pixel provenance, or `None` when not armed.
1130    #[cfg(feature = "debug-hooks")]
1131    #[must_use]
1132    pub fn pixel_provenance(&self) -> Option<&rustynes_ppu::PixelProvenanceFrame> {
1133        self.bus.ppu.pixel_provenance()
1134    }
1135
1136    /// Arm or disarm **audio** provenance (v2.3.7 "Overtone").
1137    ///
1138    /// Off by default. Arming allocates the per-register write attribution and
1139    /// the per-CPU-cycle mix trace; disarming frees both. Output-only — nothing
1140    /// recorded is read back into synthesis or carried in the save state, so the
1141    /// deterministic audio contract is unaffected either way.
1142    #[cfg(feature = "debug-hooks")]
1143    pub fn set_audio_provenance(&mut self, enabled: bool) {
1144        self.bus.apu.set_audio_provenance(enabled);
1145    }
1146
1147    /// Whether audio provenance is armed.
1148    #[cfg(feature = "debug-hooks")]
1149    #[must_use]
1150    pub const fn audio_provenance_armed(&self) -> bool {
1151        self.bus.apu.audio_provenance_armed()
1152    }
1153
1154    /// The per-register write attribution — which instruction last wrote each of
1155    /// `$4000-$4017` — or `None` when disarmed.
1156    #[cfg(feature = "debug-hooks")]
1157    #[must_use]
1158    pub fn register_attribution(&self) -> Option<&rustynes_apu::provenance::RegisterAttribution> {
1159        self.bus.apu.register_attribution()
1160    }
1161
1162    /// This frame's per-CPU-cycle mix trace, or `None` when disarmed.
1163    #[cfg(feature = "debug-hooks")]
1164    #[must_use]
1165    pub fn mix_trace(&self) -> Option<&rustynes_apu::provenance::MixTrace> {
1166        self.bus.apu.mix_trace()
1167    }
1168
1169    /// Lift the audio provenance stores out for a same-timeline restore.
1170    ///
1171    /// The audio counterpart of [`Self::take_provenance`], and it exists for the
1172    /// identical reason: run-ahead's rollback runs AFTER the visible frame is
1173    /// produced and BEFORE the frontend releases the emulator lock, so a store
1174    /// the restore clears can never be observed by the UI. That is exactly how
1175    /// Pixel Provenance shipped non-functional from v2.3.2 to v2.3.6. Take
1176    /// before the restore, [`Self::put_audio_provenance`] after.
1177    ///
1178    /// Save-state loads and netplay rollback still clear, unchanged — those are
1179    /// genuine timeline changes. Run-ahead's rollback is not.
1180    #[cfg(feature = "debug-hooks")]
1181    #[must_use]
1182    pub fn take_audio_provenance(&mut self) -> rustynes_apu::provenance::AudioProvenanceStash {
1183        self.bus.apu.take_audio_provenance()
1184    }
1185
1186    /// Put back stores taken by [`Self::take_audio_provenance`].
1187    #[cfg(feature = "debug-hooks")]
1188    pub fn put_audio_provenance(&mut self, stash: rustynes_apu::provenance::AudioProvenanceStash) {
1189        self.bus.apu.put_audio_provenance(stash);
1190    }
1191
1192    /// v2.3.6 — move both provenance stores out, leaving them unarmed.
1193    ///
1194    /// For a host that performs a **same-timeline** restore whose result the user
1195    /// is about to inspect. [`Self::restore`] and [`Self::restore_quiet`] both
1196    /// clear the stores, which is correct when the restore replaces the timeline
1197    /// the records describe — and wrong for run-ahead, whose rollback is the last
1198    /// thing before the UI reads, so the clear discards the record for the frame
1199    /// actually on screen. Take before the restore, [`Self::put_provenance`]
1200    /// after.
1201    ///
1202    /// A move, not a copy: the stores are boxed, so this is two pointer moves.
1203    /// See [`rustynes_ppu::ProvenanceStash`].
1204    #[cfg(feature = "debug-hooks")]
1205    pub const fn take_provenance(&mut self) -> rustynes_ppu::ProvenanceStash {
1206        self.bus.ppu.take_provenance()
1207    }
1208
1209    /// Put back stores taken by [`Self::take_provenance`].
1210    #[cfg(feature = "debug-hooks")]
1211    pub fn put_provenance(&mut self, stash: rustynes_ppu::ProvenanceStash) {
1212        self.bus.ppu.put_provenance(stash);
1213    }
1214
1215    /// Resolve a PPU-space nametable address (`$2000-$3EFF`) to the physical
1216    /// internal-CIRAM offset it reads, applying the mapper's mirroring and any
1217    /// per-game mirroring override.
1218    ///
1219    /// This is what turns a [`rustynes_ppu::PixelProvenance::nt_addr`] into the
1220    /// key [`rustynes_ppu::WriteAttribution::ciram`] is indexed by, so the
1221    /// provenance panel can go from "this pixel's tile came from `$2002`" to
1222    /// "and instruction X wrote that byte" without reimplementing mirroring.
1223    ///
1224    /// Returns `None` for an address outside the nametable window.
1225    ///
1226    /// # Boards with mapper-supplied nametable memory
1227    ///
1228    /// On `MMC5` (`ExRAM` nametables) and 4-screen boards, some nametable writes are
1229    /// absorbed by the mapper via `PpuBus::write_nametable` and never reach
1230    /// internal CIRAM. This function still returns the CIRAM offset the standard
1231    /// mirroring would select, because the only way to know whether the mapper
1232    /// absorbed a particular write is to *perform* one — `write_nametable` takes
1233    /// `&mut self` and has side effects, and a read-only query has no business
1234    /// inventing one. Callers should treat a missing attribution on such a board
1235    /// as "the mapper owns this byte", which is what it means.
1236    #[cfg(feature = "debug-hooks")]
1237    #[must_use]
1238    pub fn ciram_offset_for_nametable_addr(&self, addr: u16) -> Option<usize> {
1239        let a = addr & 0x3FFF;
1240        if !(0x2000..0x3F00).contains(&a) {
1241            return None;
1242        }
1243        let nt_addr = if a >= 0x3000 { a - 0x1000 } else { a };
1244        Some((self.bus.resolve_nametable_address(nt_addr) as usize) & 0x07FF)
1245    }
1246
1247    /// v1.1.0 beta.3 (T-110-E2) — start/stop the Lua bus-access log. While on,
1248    /// the bus records this frame's CPU reads + writes (with values); the log is
1249    /// reset at each [`Self::run_frame`]. Default off; output-only. Enabled by
1250    /// the scripting engine only while `onRead`/`onWrite` callbacks exist.
1251    #[cfg(feature = "debug-hooks")]
1252    pub const fn set_access_logging(&mut self, enabled: bool) {
1253        self.bus.set_access_logging(enabled);
1254    }
1255
1256    /// Whether the bus-access log is recording.
1257    #[cfg(feature = "debug-hooks")]
1258    #[must_use]
1259    pub const fn access_logging(&self) -> bool {
1260        self.bus.access_logging()
1261    }
1262
1263    /// The current frame's captured CPU bus accesses (for the Lua engine).
1264    #[cfg(feature = "debug-hooks")]
1265    #[must_use]
1266    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
1267    pub fn accesses(&self) -> &[crate::bus::AccessRec] {
1268        self.bus.accesses()
1269    }
1270
1271    /// v1.1.0 beta.3 (T-110-E2) — start/stop the per-frame exec-PC log for the
1272    /// Lua `onExec` callback. Independent of the Trace Logger (`set_trace_enabled`),
1273    /// so enabling it does not disturb the debugger's trace recording. Cleared
1274    /// every [`Self::run_frame`]; output-only.
1275    #[cfg(feature = "debug-hooks")]
1276    pub const fn set_exec_logging(&mut self, enabled: bool) {
1277        self.exec_logging = enabled;
1278    }
1279
1280    /// Whether the per-frame exec-PC log is recording.
1281    #[cfg(feature = "debug-hooks")]
1282    #[must_use]
1283    pub const fn exec_logging(&self) -> bool {
1284        self.exec_logging
1285    }
1286
1287    /// This frame's executed PCs, in execution order (for the Lua engine).
1288    #[cfg(feature = "debug-hooks")]
1289    #[must_use]
1290    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
1291    pub fn exec_log(&self) -> &[u16] {
1292        &self.exec_log
1293    }
1294
1295    /// `true` if the running program read a controller port (`$4016`/`$4017`)
1296    /// during the most recent [`Self::run_frame`] — the inverse of a `TAStudio`
1297    /// "lag frame" (v1.6.0 Workstream A3). The greenzone / piano-roll lag log
1298    /// queries this each frame. Output-only; `debug-hooks`-gated, so the
1299    /// shipped build is byte-identical and the determinism contract holds.
1300    #[cfg(feature = "debug-hooks")]
1301    #[must_use]
1302    pub const fn was_input_polled_this_frame(&self) -> bool {
1303        self.bus.controller_polled()
1304    }
1305
1306    /// v1.2.0 (T-110-E1) — start/stop the per-frame interrupt-service log for
1307    /// the Lua `onNmi` / `onIrq` callbacks. The log records this frame's
1308    /// committed NMI / IRQ / BRK service entries (captured at the CPU's
1309    /// service-vector commit point, NOT the speculative poll sampler); it is
1310    /// cleared at each [`Self::run_frame`]. Default off; output-only. Enabled by
1311    /// the scripting engine only while `onNmi`/`onIrq` callbacks exist. Mirrors
1312    /// [`Self::set_exec_logging`].
1313    #[cfg(feature = "debug-hooks")]
1314    pub const fn set_interrupt_logging(&mut self, enabled: bool) {
1315        self.bus.set_interrupt_logging(enabled);
1316    }
1317
1318    /// Whether the per-frame interrupt-service log is recording.
1319    #[cfg(feature = "debug-hooks")]
1320    #[must_use]
1321    pub const fn interrupt_logging(&self) -> bool {
1322        self.bus.interrupt_logging()
1323    }
1324
1325    /// This frame's committed interrupt-service entries, in service order (for
1326    /// the Lua engine). Mirrors [`Self::exec_log`] / [`Self::accesses`].
1327    #[cfg(feature = "debug-hooks")]
1328    #[must_use]
1329    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
1330    pub fn interrupt_log(&self) -> &[crate::bus::InterruptRec] {
1331        self.bus.interrupts()
1332    }
1333
1334    /// Borrow the framebuffer (RGBA8, 256x240).
1335    #[must_use]
1336    pub fn framebuffer(&self) -> &[u8] {
1337        self.bus.framebuffer()
1338    }
1339
1340    /// v1.7.0 "Forge" Workstream B (B3) — overwrite the RGBA8 output framebuffer
1341    /// (the Lua `emu:setScreenBuffer(t)` paints output only). Output-only; see
1342    /// [`rustynes_ppu::Ppu::debug_set_framebuffer`]. Reached only through the
1343    /// script crate's gated post-frame path, so the shipped build is
1344    /// byte-identical and the determinism contract holds. `debug-hooks`-gated.
1345    #[cfg(feature = "debug-hooks")]
1346    pub fn debug_set_framebuffer(&mut self, rgba: &[u8]) {
1347        self.bus.debug_set_framebuffer(rgba);
1348    }
1349
1350    /// Borrow the parallel palette-index framebuffer (256x240 `u16`s, each
1351    /// `(emphasis << 6) | colour`) for the `NES_NTSC` composite filter.
1352    /// See [`rustynes_ppu::Ppu::index_framebuffer`].
1353    #[must_use]
1354    pub fn index_framebuffer(&self) -> &[u16] {
1355        self.bus.index_framebuffer()
1356    }
1357
1358    /// v1.2.0 C3 (hd-pack) — borrow the per-pixel HD-pack tile-source buffer
1359    /// (256x240 [`rustynes_ppu::HdTileSource`] records). Each entry names the
1360    /// CHR tile that produced the pixel; the frontend HD-pack loader groups
1361    /// these by 8x8 cell, hashes the CHR bytes, and substitutes hi-res tiles.
1362    /// Output-only telemetry; the determinism contract is unaffected. See
1363    /// [`rustynes_ppu::Ppu::hd_tile_source`].
1364    #[cfg(feature = "hd-pack")]
1365    #[must_use]
1366    pub fn hd_tile_source(&self) -> &[rustynes_ppu::HdTileSource] {
1367        self.bus.hd_tile_source()
1368    }
1369
1370    /// The frame's background scroll `(x, y)` in NES pixels, for offsetting
1371    /// parallax HD-pack `<background>` layers (see
1372    /// [`rustynes_ppu::Ppu::hd_bg_scroll`]). Output-only.
1373    #[cfg(feature = "hd-pack")]
1374    #[must_use]
1375    pub const fn hd_bg_scroll(&self) -> (i32, i32) {
1376        self.bus.ppu().hd_bg_scroll()
1377    }
1378
1379    /// The per-frame NTSC composite colour phase consumed by the `NES_NTSC`
1380    /// filter (`0..=2` on NTSC; frame parity `0..=1` on PAL/Dendy). See
1381    /// [`rustynes_ppu::Ppu::ntsc_phase`].
1382    #[must_use]
1383    pub const fn ntsc_phase(&self) -> u8 {
1384        self.bus.ntsc_phase()
1385    }
1386
1387    /// The completed-frame counter (PPU frames since power-on). A monotonic,
1388    /// deterministic, save-state-restored value — the frontend uses it to phase
1389    /// turbo/autofire so the strobe is reproducible under rollback / TAS replay.
1390    #[must_use]
1391    pub const fn frame(&self) -> u64 {
1392        self.bus.ppu().frame()
1393    }
1394
1395    /// Borrow the underlying bus (debugger / tests).
1396    #[must_use]
1397    pub const fn bus(&self) -> &SystemBus {
1398        &self.bus
1399    }
1400
1401    /// Mutably borrow the underlying bus (debugger / tests).
1402    pub const fn bus_mut(&mut self) -> &mut SystemBus {
1403        &mut self.bus
1404    }
1405
1406    /// Assert or release the external /NMI pin. **Co-simulation only.**
1407    ///
1408    /// v2.5.1, [ADR 0038]. Rung 2's interrupt sweep needs the same stimulus on
1409    /// both sides, and this emulator had no way to receive it: its /NMI comes
1410    /// from the PPU and its /IRQ from the APU frame counter or a mapper, none of
1411    /// which exist at the CPU rung of the co-simulation.
1412    ///
1413    /// `true` asserts the pin. It is OR'd into the CPU's existing poll and
1414    /// consumed on the edge exactly as a PPU-generated NMI is, so the CPU cannot
1415    /// tell the two apart -- which is the property that makes the sweep test the
1416    /// CPU rather than the injection. It does **not** bypass the poll, force a
1417    /// vector, or short-circuit the sequence.
1418    ///
1419    /// Gated behind `cosim-interrupt-inject`, which nothing in the workspace
1420    /// enables. A default build does not contain this method or its state.
1421    ///
1422    /// [ADR 0038]: https://github.com/doublegate/RustyNES/blob/main/docs/adr/0038-cosim-interrupt-injection-api.md
1423    #[cfg(feature = "cosim-interrupt-inject")]
1424    pub const fn inject_nmi(&mut self, asserted: bool) {
1425        self.bus.set_inject_nmi(asserted);
1426    }
1427
1428    /// Assert or release the external /IRQ pin. **Co-simulation only.**
1429    ///
1430    /// Level-sensitive, as the pin is: it is masked by `I` through the CPU's own
1431    /// logic, and a pulse shorter than a poll is missed. Holding it asserted
1432    /// across several cycles is how a real device drives it. See
1433    /// [`Self::inject_nmi`] for the contract and the ADR.
1434    #[cfg(feature = "cosim-interrupt-inject")]
1435    pub const fn inject_irq(&mut self, asserted: bool) {
1436        self.bus.set_inject_irq(asserted);
1437    }
1438
1439    /// Borrow the CPU (debugger / tests).
1440    #[must_use]
1441    pub const fn cpu(&self) -> &Cpu {
1442        &self.cpu
1443    }
1444
1445    /// Cumulative CPU cycle count.
1446    #[must_use]
1447    pub const fn cycle(&self) -> u64 {
1448        self.bus.cycle()
1449    }
1450
1451    /// `true` when the CPU has executed a JAM/KIL/STP and is halted.
1452    ///
1453    /// (v2.0.0 beta.5: the `VsDualSystem` soft-lockstep driver guards its
1454    /// per-instruction stepping — the pre-existing debugger
1455    /// [`Self::step_instruction`] — on this.)
1456    #[must_use]
1457    pub const fn is_jammed(&self) -> bool {
1458        self.cpu.is_jammed()
1459    }
1460
1461    /// Cartridge region (NTSC / PAL / Dendy / Multi). Drives wall-clock
1462    /// frame pacing in the frontend and clock dividers in the chip cores.
1463    #[must_use]
1464    pub const fn region(&self) -> Region {
1465        match self.bus.region() {
1466            rustynes_mappers::Region::Pal => Region::Pal,
1467            rustynes_mappers::Region::Dendy => Region::Dendy,
1468            // iNES 1.0 "Multi" cartridges are treated as NTSC for pacing
1469            // (matches the PPU / APU init in `SystemBus::with_sample_rate`).
1470            _ => Region::Ntsc,
1471        }
1472    }
1473
1474    /// Length in bytes of the loaded cartridge's PRG-ROM (read-only metadata).
1475    ///
1476    /// Exposed for the Lua scripting `cart:prg_size()` query (and any other
1477    /// read-only consumer); does not touch deterministic state.
1478    #[must_use]
1479    pub const fn prg_rom_len(&self) -> usize {
1480        self.bus.prg_rom_len()
1481    }
1482
1483    /// Length in bytes of the loaded cartridge's CHR-ROM (0 when the board uses
1484    /// CHR-RAM). Read-only metadata; backs the Lua `cart:chr_size()` query.
1485    #[must_use]
1486    pub const fn chr_rom_len(&self) -> usize {
1487        self.bus.chr_rom_len()
1488    }
1489
1490    /// The loaded cartridge's iNES / NES 2.0 mapper id, after any load-time
1491    /// header correction (backs the Lua `cart:mapper_id()`, the ROM-info
1492    /// panel and the mobile `RomInfo`). A Famicom Disk System image reports
1493    /// 20 and an NSF 31, the ids their synthetic cartridges carry.
1494    ///
1495    /// v2.9.8 — read from the cartridge. It used to read the mapper's debug
1496    /// view, whose default `debug_info` names mapper 0, so every board
1497    /// without its own override (`UxROM`, CNROM, `AxROM`, ...) reported 0, and
1498    /// an NSF reported 0 rather than 31.
1499    #[must_use]
1500    pub const fn mapper_id(&self) -> u16 {
1501        self.bus.cart.mapper_id
1502    }
1503
1504    /// v2.9.8 — the loaded cartridge's NES 2.0 submapper (0 for an iNES 1.0
1505    /// image, which has none), after any load-time header correction.
1506    #[must_use]
1507    pub const fn submapper(&self) -> u8 {
1508        self.bus.cart.submapper
1509    }
1510
1511    /// Wall-clock frame duration for this cartridge's region. The frontend
1512    /// uses this to pace emulator advance independently of monitor refresh
1513    /// rate — without it, `Fifo` present mode on a 144 Hz monitor would
1514    /// run the emulator 2.4× too fast.
1515    #[must_use]
1516    pub const fn frame_duration(&self) -> Duration {
1517        match self.region() {
1518            Region::Pal => FRAME_DURATION_PAL,
1519            Region::Dendy => FRAME_DURATION_DENDY,
1520            Region::Ntsc => FRAME_DURATION_NTSC,
1521        }
1522    }
1523
1524    /// Drain accumulated audio samples at the host sample rate. Call once per
1525    /// frame from the frontend's audio thread or batch driver.
1526    ///
1527    /// The samples are **bipolar and DC-blocked**, not the mixer's raw
1528    /// `[0.0, ~1.0]`: measured over 900 frames (v2.8.1), the 2A03 alone spans
1529    /// about `-0.385..0.223` with a zero mean, and expansion audio reaches
1530    /// further (a Namco 163 channel, `+/-0.870`). Nothing measured passes
1531    /// `+/-1.0`, so a frontend converting to `i16` should put full scale at
1532    /// `1.0`. This comment said `[0.0, ~1.0]` until v2.8.1.
1533    pub fn drain_audio(&mut self) -> Vec<f32> {
1534        self.bus.drain_audio()
1535    }
1536
1537    /// Set the buttons currently held on player `port`. Ports 0/1 are the
1538    /// standard controllers (`$4016`/`$4017`); ports 2/3 are players 3/4 on
1539    /// the Four Score adapter (only polled when [`Self::set_four_score`] is
1540    /// on). The change takes effect on the next strobe edge.
1541    ///
1542    /// # Panics
1543    ///
1544    /// Panics if `port` is not in `0..=3`.
1545    pub const fn set_buttons(&mut self, port: usize, buttons: Buttons) {
1546        self.bus.set_buttons(port, buttons);
1547    }
1548
1549    /// Get the buttons currently held on player `port` (0/1 = `$4016`/`$4017`;
1550    /// 2/3 = Four Score players 3/4). Read-only; does not advance emulator
1551    /// state.
1552    ///
1553    /// Used by the TAS movie recorder (`crate::movie`) to capture the inputs
1554    /// applied before each [`Self::run_frame`]. (Movies record players 1 & 2;
1555    /// Four Score players 3/4 are not part of the `.rnm` stream.)
1556    ///
1557    /// # Panics
1558    ///
1559    /// Panics if `port` is not in `0..=3`.
1560    #[must_use]
1561    pub const fn buttons(&self, port: usize) -> Buttons {
1562        self.bus.controller(port).buttons()
1563    }
1564
1565    /// Enable/disable the Four Score 4-player adapter. Off by default; while
1566    /// off, controller reads are byte-identical to the standard two-pad
1567    /// behavior (the determinism contract and save-states are unaffected).
1568    /// When on, players 3/4 (ports 2/3) are multiplexed onto `$4016`/`$4017`
1569    /// across a 24-read serial sequence.
1570    pub const fn set_four_score(&mut self, enabled: bool) {
1571        self.bus.set_four_score(enabled);
1572    }
1573
1574    /// Whether the Four Score adapter is currently enabled.
1575    #[must_use]
1576    pub const fn four_score(&self) -> bool {
1577        self.bus.four_score()
1578    }
1579
1580    // --- Vs. System DIP switches + coin/service inputs ---
1581
1582    /// True when the running cart is Nintendo Vs. System arcade hardware
1583    /// (NES 2.0 console type = Vs. System). The RGB PPU + DIP/coin inputs only
1584    /// take effect on such carts.
1585    #[must_use]
1586    pub fn is_vs_system(&self) -> bool {
1587        self.bus.is_vs_system()
1588    }
1589
1590    /// True when the cart's header marks a Vs. `DualSystem` board (two CPUs /
1591    /// two PPUs; NES 2.0 byte-13 high nibble = Vs. hardware type 5/6).
1592    ///
1593    /// Detection only: this single-system core cannot boot a `DualSystem` title
1594    /// past its attract handshake, so the frontend uses this to surface a clear
1595    /// note. The two-CPU/two-PPU emulation is a documented v2.0 deferral
1596    /// (`docs/audit/vs-dualsystem-design-2026-06-11.md`).
1597    #[must_use]
1598    pub const fn is_vs_dual_system(&self) -> bool {
1599        self.bus.is_vs_dual_system()
1600    }
1601
1602    /// Set the Vs. System 8-bit DIP-switch bank (switch 1 = bit 0 .. switch 8 =
1603    /// bit 7). Read through the upper bits of `$4016`/`$4017`. No effect on
1604    /// non-Vs. carts; the standard controller read stays byte-identical.
1605    pub const fn set_vs_dip(&mut self, dip: u8) {
1606        self.bus.set_vs_dip(dip);
1607    }
1608
1609    /// Current Vs. System DIP-switch bank.
1610    #[must_use]
1611    pub const fn vs_dip(&self) -> u8 {
1612        self.bus.vs_dip()
1613    }
1614
1615    /// Override the Vs. System PPU type and re-apply the output palette.
1616    ///
1617    /// iNES-1.0 Vs. dumps default to the 2C03 palette (no NES 2.0 byte-13);
1618    /// the per-game database ([`crate::vs_db`]) supplies the correct
1619    /// 2C04-000x / 2C05 type, which the frontend applies through this setter.
1620    /// Mostly the colour LUT the PPU emits through, but a 2C05 also returns
1621    /// its identification bits in `$2002` and swaps `$2000` / `$2001`, which
1622    /// game code reads -- so it is emulation state, and movies and netplay
1623    /// carry it (v2.9.8, [`crate::HardwareOptions`]). (Until v2.9.8 this said
1624    /// "never game logic", which the 2C05 path contradicts.) No effect on
1625    /// non-Vs. carts.
1626    pub const fn set_vs_ppu_type(&mut self, t: rustynes_mappers::VsPpuType) {
1627        self.bus.set_vs_ppu_type(t);
1628    }
1629
1630    /// v2.9.8 — the Vs. System PPU type in effect: the header's, or the one a
1631    /// later [`Self::set_vs_ppu_type`] installed. [`rustynes_mappers::VsPpuType::None`]
1632    /// on every non-Vs. cart.
1633    ///
1634    /// Read by [`crate::HardwareOptions::capture`]: on a 2C05 the type also
1635    /// sets the `$2002` identification bits and swaps `$2000` / `$2001`, which
1636    /// game code reads, so a movie or a netplay peer must run the same one.
1637    #[must_use]
1638    pub const fn vs_ppu_type(&self) -> rustynes_mappers::VsPpuType {
1639        self.bus.cart.vs_ppu_type
1640    }
1641
1642    /// v2.9.8 — the parsed cartridge description the machine was built from
1643    /// (mapper, submapper, mirroring, RAM sizes, console type ...), after any
1644    /// load-time header correction. Crate-internal: read by
1645    /// [`crate::BoardDescription::capture`].
1646    #[must_use]
1647    pub(crate) const fn cartridge(&self) -> &rustynes_mappers::Cartridge {
1648        &self.bus.cart
1649    }
1650
1651    /// Latch a Vs. System coin insertion on the given acceptor (0 = #1, 1 = #2).
1652    /// Reads true for a real-hardware ~40-70 ms window; the frontend should
1653    /// clear it (see [`Self::clear_coin`]) after a few frames.
1654    pub const fn insert_coin(&mut self, acceptor: u8) {
1655        self.bus.insert_coin(acceptor);
1656    }
1657
1658    /// Clear all latched Vs. System coin-insert signals.
1659    pub const fn clear_coin(&mut self) {
1660        self.bus.clear_coin();
1661    }
1662
1663    /// Set / clear the Vs. System service button.
1664    pub const fn set_vs_service(&mut self, pressed: bool) {
1665        self.bus.set_vs_service(pressed);
1666    }
1667
1668    // --- Famicom Disk System disk control (Stage 2b) ---
1669
1670    /// Number of disk sides in the inserted FDS image. Returns 0 for cartridge
1671    /// builds (so a frontend can branch on "is this an FDS game?").
1672    #[must_use]
1673    pub fn disk_side_count(&self) -> usize {
1674        self.bus.disk_side_count()
1675    }
1676
1677    /// The currently inserted FDS disk side index, or `None` when ejected (or
1678    /// for a cartridge build). A game that prompts "insert side B" is asking the
1679    /// user to call [`Self::set_disk_side`].
1680    #[must_use]
1681    pub fn inserted_disk_side(&self) -> Option<usize> {
1682        self.bus.inserted_disk_side()
1683    }
1684
1685    /// Insert FDS side `i` (`Some(i)`) or eject the disk (`None`). Inserting
1686    /// resets the head and opens a short deterministic "not ready" window (the
1687    /// BIOS polls `$4032` and waits for ready); an out-of-range index is
1688    /// ignored. No-op on cartridge builds. This is how the user complies with a
1689    /// game's "insert side N" prompt.
1690    pub fn set_disk_side(&mut self, side: Option<usize>) {
1691        self.bus.set_disk_side(side);
1692    }
1693
1694    /// Start recording the diagnostic FDS read-stream trace (the `$4031` disk-byte
1695    /// stream + `$4025` control writes + side changes). Off by default and
1696    /// observation-only — it never affects emulation, so the determinism contract
1697    /// holds. Drain it with [`Self::take_fds_trace`]. No-op on cartridge builds.
1698    /// Used by the `fds_trace` diagnostic harness to debug disk-read / side-swap
1699    /// failures (e.g. the Kid Icarus side-B `ERR.07` stall).
1700    pub fn enable_fds_trace(&mut self) {
1701        self.bus.enable_fds_trace();
1702    }
1703
1704    /// Drain the accumulated FDS read-stream trace records. Empty for cartridge
1705    /// builds or when [`Self::enable_fds_trace`] was never called.
1706    #[must_use]
1707    pub fn take_fds_trace(&mut self) -> Vec<rustynes_mappers::FdsTraceRec> {
1708        self.bus.take_fds_trace()
1709    }
1710
1711    /// Re-serialize the (possibly-modified) FDS disk image to the headerless
1712    /// `.fds` byte layout so the host can write it to a side-car `.fds.sav`
1713    /// (keyed by [`Self::rom_sha256`]; a console booted from that file reports
1714    /// the same key once the host calls [`Self::set_rom_identity`]). Empty for
1715    /// cartridge builds.
1716    #[must_use]
1717    pub fn disk_image_bytes(&self) -> Vec<u8> {
1718        self.bus.disk_image_bytes()
1719    }
1720
1721    /// Whether the FDS disk image has unsaved writes since the last
1722    /// [`Self::clear_disk_dirty`]. A frontend checks this on quit / periodically
1723    /// to decide whether to persist the disk.
1724    #[must_use]
1725    pub fn disk_is_dirty(&self) -> bool {
1726        self.bus.disk_is_dirty()
1727    }
1728
1729    /// Clear the FDS disk dirty flag after persisting the image.
1730    pub fn clear_disk_dirty(&mut self) {
1731        self.bus.clear_disk_dirty();
1732    }
1733
1734    /// Mark the inserted FDS disk read-only (`true`) or writable (`false`,
1735    /// the default). Drives the `$4032` write-protect flag; a write-protected
1736    /// disk drops bytes in write mode without modifying the medium.
1737    pub fn set_disk_write_protected(&mut self, protected: bool) {
1738        self.bus.set_disk_write_protected(protected);
1739    }
1740
1741    /// Attach a non-standard overlay input device on `port` (0 = `$4016`, 1 =
1742    /// `$4017`). Pass `None` to unplug it and return the port to the standard
1743    /// controller / Four Score path (byte-identical reads). Devices are
1744    /// unplugged on power-cycle.
1745    ///
1746    /// # Panics
1747    ///
1748    /// Panics if `port` is not in `0..=1`.
1749    pub fn set_expansion_device(&mut self, port: usize, device: Option<InputDevice>) {
1750        self.bus.set_expansion_device(port, device);
1751    }
1752
1753    /// Borrow the overlay device attached to `port` (0 = `$4016`, 1 =
1754    /// `$4017`), if any.
1755    ///
1756    /// # Panics
1757    ///
1758    /// Panics if `port` is not in `0..=1`.
1759    #[must_use]
1760    pub const fn expansion_device(&self, port: usize) -> &Option<InputDevice> {
1761        self.bus.expansion_device(port)
1762    }
1763
1764    /// Attach an Arkanoid "Vaus" paddle on `port` (typically port 1 / `$4017`)
1765    /// and set its position + fire state. `position` is the raw 8-bit
1766    /// potentiometer value (`$00` far-left .. `$FF` far-right); `fire` is the
1767    /// single button. Convenience wrapper that attaches the device if absent
1768    /// then updates it.
1769    ///
1770    /// # Panics
1771    ///
1772    /// Panics if `port` is not in `0..=1`.
1773    pub fn set_paddle(&mut self, port: usize, position: u8, fire: bool) {
1774        if !matches!(self.bus.expansion_device(port), Some(InputDevice::Vaus(_))) {
1775            self.bus.set_expansion_device(
1776                port,
1777                Some(InputDevice::Vaus(crate::input_device::VausState::new())),
1778            );
1779        }
1780        self.bus.set_paddle(port, position, fire);
1781    }
1782
1783    /// Attach an NES Zapper light gun on `port` (typically port 1 / `$4017`)
1784    /// and set its aim point + trigger. `(x, y)` is the screen pixel the gun is
1785    /// aimed at (0..256, 0..240; out of range = off-screen); `trigger` is the
1786    /// trigger state. Convenience wrapper that attaches the device if absent
1787    /// then updates it.
1788    ///
1789    /// Light detection is sampled from the framebuffer at the end of each
1790    /// [`Self::run_frame`]; the determinism contract holds because the sample
1791    /// only runs when a Zapper is attached (the no-device path is unchanged).
1792    ///
1793    /// # Panics
1794    ///
1795    /// Panics if `port` is not in `0..=1`.
1796    pub fn set_zapper(&mut self, port: usize, x: u16, y: u16, trigger: bool) {
1797        if !matches!(
1798            self.bus.expansion_device(port),
1799            Some(InputDevice::Zapper(_))
1800        ) {
1801            self.bus.set_expansion_device(
1802                port,
1803                Some(InputDevice::Zapper(crate::input_device::ZapperState::new())),
1804            );
1805        }
1806        self.bus.set_zapper(port, x, y, trigger);
1807    }
1808
1809    /// A3 (v2.2.3): enable the **beam-relative** Zapper light model.
1810    ///
1811    /// **Default ON since v2.3.6** (was off in v2.2.3-v2.3.5). See
1812    /// [`crate::bus::SystemBus::set_zapper_temporal_light`] for the model and
1813    /// for why it was promoted; in short, the light bit is a function of where
1814    /// the CRT beam is at the moment of the read (dark before the beam paints
1815    /// the aim row, lit for the ~19-26-scanline photodiode hold, dark after)
1816    /// instead of one answer for the whole frame — and the frame model made a
1817    /// *Duck Hunt* hit impossible.
1818    ///
1819    /// Pass `false` to restore the pre-v2.3.6 frame-granular behaviour.
1820    ///
1821    /// Deterministic either way: the temporal answer is a pure function of
1822    /// framebuffer + aim + current scanline and holds no extra state, so it
1823    /// adds nothing to serialize and cannot desync a save state or a netplay
1824    /// rollback.
1825    pub const fn set_zapper_temporal_light(&mut self, on: bool) {
1826        self.bus.set_zapper_temporal_light(on);
1827    }
1828
1829    /// Whether the beam-relative Zapper light model is enabled (A3).
1830    #[must_use]
1831    pub const fn zapper_temporal_light(&self) -> bool {
1832        self.bus.zapper_temporal_light()
1833    }
1834
1835    /// Drive the Famicom built-in **microphone** (read on `$4016` bit 2).
1836    ///
1837    /// The hardwired second Famicom controller carries a push-to-talk mic that
1838    /// games poll on `$4016.D2` (e.g. *Zelda*'s Pols Voice, *Kid Icarus*). Pass
1839    /// `pressed = true` while the frontend's mic key is held / an audio source
1840    /// crosses the loudness threshold. Additive and opt-in: `false` (the
1841    /// default) keeps the `$4016` read byte-identical to a stock NES.
1842    pub const fn set_microphone(&mut self, pressed: bool) {
1843        self.bus.set_microphone(pressed);
1844    }
1845
1846    /// Attach an NES Power Pad / Family Fun Fitness mat on `port` (typically
1847    /// port 1 / `$4017`) and set its live button mask (bit `i` = mat button
1848    /// `i+1`, 0..=11). Convenience wrapper that attaches the device if absent
1849    /// then updates it. Opt-in: the no-device path stays byte-identical.
1850    ///
1851    /// # Panics
1852    ///
1853    /// Panics if `port` is not in `0..=1`.
1854    pub fn set_power_pad(&mut self, port: usize, buttons: u16) {
1855        if !matches!(
1856            self.bus.expansion_device(port),
1857            Some(InputDevice::PowerPad(_))
1858        ) {
1859            self.bus.set_expansion_device(
1860                port,
1861                Some(InputDevice::PowerPad(
1862                    crate::input_device::PowerPadState::new(),
1863                )),
1864            );
1865        }
1866        self.bus.set_power_pad(port, buttons);
1867    }
1868
1869    /// v1.6.0 B3 — the latched standard-controller button bitmask for `port`
1870    /// (`0` = P1 / `$4016`, `1` = P2 / `$4017`; `2`/`3` are the Four Score
1871    /// players), in [`Buttons`](crate::Buttons) bit order (A = bit 0 .. Right =
1872    /// bit 7). Read-only and side-effect-free — it reads the latched state, not
1873    /// the shift register, so it never perturbs a controller poll. Exposed for
1874    /// the Lua `joypad.get` query.
1875    ///
1876    /// # Panics
1877    ///
1878    /// Panics if `port` is not in `0..=3`.
1879    #[must_use]
1880    pub fn controller_buttons(&self, port: usize) -> u8 {
1881        assert!(port <= 3, "controller port {port} out of range (0..=3)");
1882        self.bus.controller(port).buttons().bits()
1883    }
1884
1885    /// v1.2.0 Workstream D — attach a SNES-style serial mouse on `port` (0 =
1886    /// `$4016`, 1 = `$4017`) and set its movement / buttons / sensitivity.
1887    /// `(dx, dy)` are the signed per-frame deltas (clamped to +/-127 on latch);
1888    /// `sensitivity` is 0 (low) / 1 (medium) / 2 (high). Convenience wrapper
1889    /// that attaches the device if absent then updates it. Opt-in: the no-device
1890    /// path stays byte-identical.
1891    ///
1892    /// # Panics
1893    ///
1894    /// Panics if `port` is not in `0..=1`.
1895    pub fn set_snes_mouse(
1896        &mut self,
1897        port: usize,
1898        dx: i16,
1899        dy: i16,
1900        left: bool,
1901        right: bool,
1902        sensitivity: u8,
1903    ) {
1904        if !matches!(
1905            self.bus.expansion_device(port),
1906            Some(InputDevice::SnesMouse(_))
1907        ) {
1908            self.bus.set_expansion_device(
1909                port,
1910                Some(InputDevice::SnesMouse(
1911                    crate::input_device::SnesMouseState::new(),
1912                )),
1913            );
1914        }
1915        self.bus
1916            .set_snes_mouse(port, dx, dy, left, right, sensitivity);
1917    }
1918
1919    /// v1.2.0 Workstream D — attach a Famicom Family BASIC keyboard on `port`
1920    /// (typically port 1 / `$4017`) and set its pressed-key bitmap. `keys` is
1921    /// one byte per matrix row (`keys[row]` bits 0..=3 = column-half 0 keys,
1922    /// bits 4..=7 = column-half 1 keys); the frontend builds it from host keys.
1923    /// Convenience wrapper that attaches the device if absent then updates it.
1924    /// Opt-in: the no-device path stays byte-identical.
1925    ///
1926    /// # Panics
1927    ///
1928    /// Panics if `port` is not in `0..=1`.
1929    pub fn set_family_keyboard(&mut self, port: usize, keys: [u8; 9]) {
1930        if !matches!(
1931            self.bus.expansion_device(port),
1932            Some(InputDevice::FamilyKeyboard(_))
1933        ) {
1934            self.bus.set_expansion_device(
1935                port,
1936                Some(InputDevice::FamilyKeyboard(
1937                    crate::input_device::FamilyKeyboardState::new(),
1938                )),
1939            );
1940        }
1941        self.bus.set_family_keyboard(port, keys);
1942    }
1943
1944    /// v1.3.0 Workstream F1 — attach a Bandai **Family Trainer** mat on `port`
1945    /// and set its 12-button mask (bit `i` = mat button `i+1`). The Family
1946    /// Trainer is layout-equivalent to the Power Pad and reuses its scan; this
1947    /// attaches the [`InputDevice::FamilyTrainer`] variant (distinct from
1948    /// [`Self::set_power_pad`] so the selected device round-trips through a
1949    /// save-state). Opt-in: the no-device path stays byte-identical.
1950    ///
1951    /// # Panics
1952    ///
1953    /// Panics if `port` is not in `0..=1`.
1954    pub fn set_family_trainer(&mut self, port: usize, buttons: u16) {
1955        if !matches!(
1956            self.bus.expansion_device(port),
1957            Some(InputDevice::FamilyTrainer(_))
1958        ) {
1959            self.bus.set_expansion_device(
1960                port,
1961                Some(InputDevice::FamilyTrainer(
1962                    crate::input_device::PowerPadState::new(),
1963                )),
1964            );
1965        }
1966        self.bus.set_family_trainer(port, buttons);
1967    }
1968
1969    /// v1.3.0 Workstream F1 — attach a **Subor keyboard** on `port` and set its
1970    /// pressed-key bitmap (one byte per matrix row, like
1971    /// [`Self::set_family_keyboard`]). The Subor keyboard reuses the Family
1972    /// BASIC keyboard matrix scan; this attaches the
1973    /// [`InputDevice::SuborKeyboard`] variant. Opt-in: the no-device path stays
1974    /// byte-identical.
1975    ///
1976    /// # Panics
1977    ///
1978    /// Panics if `port` is not in `0..=1`.
1979    pub fn set_subor_keyboard(&mut self, port: usize, keys: [u8; 9]) {
1980        if !matches!(
1981            self.bus.expansion_device(port),
1982            Some(InputDevice::SuborKeyboard(_))
1983        ) {
1984            self.bus.set_expansion_device(
1985                port,
1986                Some(InputDevice::SuborKeyboard(
1987                    crate::input_device::FamilyKeyboardState::new(),
1988                )),
1989            );
1990        }
1991        self.bus.set_subor_keyboard(port, keys);
1992    }
1993
1994    /// v1.3.0 Workstream F1 — attach a **Konami Hyper Shot** on `port` and set
1995    /// its 4-button mask (bit 0 = P1 Run, 1 = P1 Jump, 2 = P2 Run, 3 = P2 Jump).
1996    /// Opt-in: the no-device path stays byte-identical.
1997    ///
1998    /// # Panics
1999    ///
2000    /// Panics if `port` is not in `0..=1`.
2001    pub fn set_konami_hyper_shot(&mut self, port: usize, buttons: u8) {
2002        if !matches!(
2003            self.bus.expansion_device(port),
2004            Some(InputDevice::KonamiHyperShot(_))
2005        ) {
2006            self.bus.set_expansion_device(
2007                port,
2008                Some(InputDevice::KonamiHyperShot(
2009                    crate::input_device::KonamiHyperShotState::new(),
2010                )),
2011            );
2012        }
2013        self.bus.set_konami_hyper_shot(port, buttons);
2014    }
2015
2016    /// v1.3.0 Workstream F1 — attach a **Bandai Hyper Shot** (Exciting Boxing
2017    /// punching bag) on `port` and set its 8-sensor mask (bits 0..=3 = the A=0
2018    /// group, bits 4..=7 = the A=1 group). Opt-in: the no-device path stays
2019    /// byte-identical.
2020    ///
2021    /// # Panics
2022    ///
2023    /// Panics if `port` is not in `0..=1`.
2024    pub fn set_bandai_hyper_shot(&mut self, port: usize, sensors: u8) {
2025        if !matches!(
2026            self.bus.expansion_device(port),
2027            Some(InputDevice::BandaiHyperShot(_))
2028        ) {
2029            self.bus.set_expansion_device(
2030                port,
2031                Some(InputDevice::BandaiHyperShot(
2032                    crate::input_device::BandaiHyperShotState::new(),
2033                )),
2034            );
2035        }
2036        self.bus.set_bandai_hyper_shot(port, sensors);
2037    }
2038
2039    /// v1.1.0 beta.1 (T-110-B4) — set (`Some`) or clear (`None`) a per-game
2040    /// **nametable mirroring override**, a load-time correction for ROMs whose
2041    /// iNES header carries the wrong mirroring flag (supplied by the frontend's
2042    /// game database). `None` (default) defers to the mapper — byte-identical,
2043    /// so the determinism / `AccuracyCoin` contract and the core test suites are
2044    /// unaffected (they never set it). Persisted in the save-state. Does not
2045    /// affect mappers with on-cart VRAM (4-screen).
2046    pub const fn set_mirroring_override(&mut self, m: Option<rustynes_mappers::Mirroring>) {
2047        self.bus.set_mirroring_override(m);
2048    }
2049
2050    /// v2.9.8 — the per-game nametable mirroring override in effect (`None` =
2051    /// the mapper decides). See [`Self::set_mirroring_override`].
2052    #[must_use]
2053    pub const fn mirroring_override(&self) -> Option<rustynes_mappers::Mirroring> {
2054        self.bus.mirroring_override()
2055    }
2056
2057    /// Whether the loaded mapper's nametable mirroring is **hardwired** by the
2058    /// cartridge (solder pads / header bit) rather than controlled by the
2059    /// mapper's own registers at runtime.
2060    ///
2061    /// The frontend consults this before honoring a game-database mirroring
2062    /// correction: a static override is only valid for a hardwired board, and
2063    /// force-applying one to a mapper that switches mirroring itself (MMC1/3/5,
2064    /// `AxROM`, VRC, …) corrupts its rendering. See
2065    /// [`rustynes_mappers::Mapper::has_hardwired_mirroring`].
2066    #[must_use]
2067    pub fn mapper_has_hardwired_mirroring(&self) -> bool {
2068        self.bus.mapper_has_hardwired_mirroring()
2069    }
2070
2071    /// Write a byte directly into CPU work RAM (`$0000-$1FFF`). Used by the
2072    /// frontend's raw RAM cheats (GameShark-style); applied *after*
2073    /// [`Self::run_frame`], so the deterministic core run loop is unchanged
2074    /// (the determinism contract holds for the no-cheat path). No-op outside
2075    /// system RAM.
2076    pub fn poke_ram(&mut self, addr: u16, value: u8) {
2077        self.bus.poke_ram(addr, value);
2078    }
2079
2080    /// v1.7.0 "Forge" Workstream A1 — debugger writeback into the PPU bus
2081    /// (`$0000-$3FFF`): CHR pattern bytes (mapper `ppu_write`, a no-op on
2082    /// CHR-ROM), nametable tiles/attributes (mapper-absorbed, else CIRAM via the
2083    /// active mirroring), and palette RAM. The PPU-bus counterpart of
2084    /// [`Self::poke_ram`].
2085    ///
2086    /// Reached only through the frontend's gated post-frame poke path (the same
2087    /// caller-side, after-[`Self::run_frame`] stage the raw RAM cheats use), so
2088    /// the deterministic core run loop is unchanged and the no-edit path is
2089    /// byte-identical. `debug-hooks`-gated.
2090    #[cfg(feature = "debug-hooks")]
2091    pub fn debug_poke_ppu(&mut self, addr: u16, value: u8) {
2092        self.bus.debug_poke_ppu(addr, value);
2093    }
2094
2095    /// v1.7.0 "Forge" Workstream A1 — debugger writeback for one OAM byte
2096    /// (`idx` = 0..256: byte 0 = Y, 1 = tile, 2 = attributes, 3 = X per
2097    /// sprite). `debug-hooks`-gated; reached only through the gated post-frame
2098    /// poke path, so the default build is byte-identical.
2099    #[cfg(feature = "debug-hooks")]
2100    pub const fn poke_oam_byte(&mut self, idx: u8, value: u8) {
2101        self.bus.debug_poke_oam(idx, value);
2102    }
2103
2104    /// v1.7.0 "Forge" Workstream B (Lua API parity) — debugger/scripted
2105    /// writeback of the CPU register file (`a`/`x`/`y`/`s`/`p` bits/`pc`). The
2106    /// structured-state counterpart of [`Self::poke_ram`], backing the Lua
2107    /// `emu:setState(t)` field map (Mesen2 parity).
2108    ///
2109    /// Reached only through the frontend / script crate's gated post-frame poke
2110    /// path (the same caller-side, after-[`Self::run_frame`] stage the raw RAM
2111    /// cheats + the other `debug_poke_*` writebacks use), so the deterministic
2112    /// core run loop is unchanged and the no-edit path is byte-identical.
2113    /// `debug-hooks`-gated. `p` is taken as a raw status-bits byte (truncated to
2114    /// the defined flags, mirroring a `PLP` / save-state restore).
2115    #[cfg(feature = "debug-hooks")]
2116    // This is a runtime register-file mutator (the structured-state counterpart
2117    // of `poke_ram`); `const` adds no value and would needlessly constrain the
2118    // body, so the `missing_const_for_fn` suggestion is declined here.
2119    #[allow(clippy::missing_const_for_fn)]
2120    pub fn debug_set_cpu_state(&mut self, a: u8, x: u8, y: u8, s: u8, p_bits: u8, pc: u16) {
2121        self.cpu.a = a;
2122        self.cpu.x = x;
2123        self.cpu.y = y;
2124        self.cpu.s = s;
2125        self.cpu.p = rustynes_cpu::Status::from_bits_truncate(p_bits);
2126        self.cpu.pc = pc;
2127    }
2128
2129    /// Read a byte from the CPU address space (`$0000-$FFFF`) for inspection,
2130    /// **without** the register side effects of a real CPU read — reading
2131    /// `$2002` does not clear the VBL flag / address latch and `$2007` does not
2132    /// advance the PPU read buffer. Used by the debugger and the Lua scripting
2133    /// API (`emu.read`); it observes state without advancing the emulator,
2134    /// preserving determinism.
2135    #[must_use]
2136    pub fn peek(&mut self, addr: u16) -> u8 {
2137        self.bus.debug_peek_cpu(addr)
2138    }
2139
2140    /// v1.2.0 C3 (hd-pack) — side-effect-free read of the PPU bus
2141    /// (`$0000-$3FFF`): CHR pattern data, nametables, palette RAM. Used by the
2142    /// HD-pack compositor to hash a tile's 16 CHR bytes. Observes state without
2143    /// advancing the emulator, preserving determinism.
2144    #[cfg(feature = "hd-pack")]
2145    #[must_use]
2146    pub fn peek_ppu(&mut self, addr: u16) -> u8 {
2147        self.bus.debug_peek_ppu(addr)
2148    }
2149
2150    /// Add a Game Genie code (6 or 8 characters, case-insensitive) that
2151    /// substitutes a byte the CPU reads from PRG-ROM (`$8000-$FFFF`).
2152    ///
2153    /// Codes are a runtime overlay — they are **not** part of the save-state
2154    /// and do not perturb the determinism contract when none are active. With
2155    /// codes active, the substituted bytes are part of the deterministic
2156    /// input (record a movie with the same codes to reproduce a run).
2157    ///
2158    /// # Errors
2159    ///
2160    /// Returns [`GenieError`] if the code string cannot be decoded.
2161    pub fn add_genie_code(&mut self, code: &str) -> Result<(), GenieError> {
2162        self.bus.add_genie_code(code)
2163    }
2164
2165    /// Remove the active Game Genie code whose canonical (upper-case) string
2166    /// matches `code`. No-op if no such code is active.
2167    pub fn remove_genie_code(&mut self, code: &str) {
2168        self.bus.remove_genie_code(code);
2169    }
2170
2171    /// Remove all active Game Genie codes.
2172    pub fn clear_genie_codes(&mut self) {
2173        self.bus.clear_genie_codes();
2174    }
2175
2176    /// Iterate the active Game Genie codes (address-sorted).
2177    pub fn genie_codes(&self) -> impl Iterator<Item = &GenieCode> {
2178        self.bus.genie_codes()
2179    }
2180
2181    /// Drain into a slice; returns the count copied.  Excess samples are
2182    /// dropped if `out` is smaller than the buffered count.
2183    pub fn drain_audio_into(&mut self, out: &mut [f32]) -> usize {
2184        self.bus.drain_audio_into(out)
2185    }
2186
2187    /// The ROM's persistent identity: SHA-256 of an iNES / NES 2.0 image's
2188    /// bytes after its 16-byte header, or of the whole image for FDS and NSF.
2189    ///
2190    /// Everything that persists per game is keyed by it: save-state slot
2191    /// directories, the battery `.sav`, cheats, the `.rns` header's ROM tag
2192    /// ([`Self::rom_hash_tag`]), movies and netplay's ROM match. Computed once
2193    /// at construction; subsequent calls are O(1). A host that boots a saved
2194    /// copy of an FDS disk replaces it with the pristine disk's hash
2195    /// ([`Self::set_rom_identity`], v2.9.9), so a game's own disk saves never
2196    /// move its identity.
2197    ///
2198    /// **Why the header is excluded (v2.9.8).** Until v2.9.8 this hashed the
2199    /// whole image as constructed, which on the desktop is the image AFTER the
2200    /// game database corrected its header. Any change to those corrections
2201    /// therefore renamed a game's saves and invalidated its states, and v2.9.8
2202    /// alone changed them three times (the dirty-tail mapper nibble, the NES 2.0
2203    /// guard, the region promotion). The header is the one part of an image
2204    /// that load-time corrections rewrite; PRG and CHR never change, so an
2205    /// identity built from them survives every past and future correction, and
2206    /// two dumps of the same game with different headers share their saves.
2207    /// The maintainer accepted the one-time break this causes (2026-10-01).
2208    #[must_use]
2209    pub const fn rom_sha256(&self) -> &[u8; 32] {
2210        &self.rom_sha256
2211    }
2212
2213    /// The identity [`Self::rom_sha256`] reports for a console built from
2214    /// `image`, computed without building one: SHA-256 of the bytes after the
2215    /// 16-byte header of an iNES / NES 2.0 image, of the whole image otherwise
2216    /// (FDS, NSF, UNIF).
2217    ///
2218    /// For a host that keys stores before, or without, constructing a console
2219    /// -- a library import that must not need the FDS BIOS, or a key
2220    /// migration (v2.9.9 re-audit NF-21: the mobile hosts keyed saves by the
2221    /// whole file's hash). It is the same function every constructor uses, so
2222    /// the two cannot drift. It does not include a later
2223    /// [`Self::set_rom_identity`].
2224    #[must_use]
2225    pub fn rom_identity_of(image: &[u8]) -> [u8; 32] {
2226        rom_identity_sha256(image)
2227    }
2228
2229    /// SHA-256 of the complete image as constructed, header included.
2230    ///
2231    /// The Vs. System database ([`crate::vs_db`]) keeps a whole-file key on
2232    /// each row as a fallback to its identity key ([`Self::rom_sha256`]), so a
2233    /// row added from a dump that was never staged stays reachable;
2234    /// [`crate::vs_db::lookup`] consults both. Nothing else is keyed by it.
2235    /// Identical to [`Self::rom_sha256`] for FDS and NSF.
2236    #[must_use]
2237    pub const fn image_sha256(&self) -> &[u8; 32] {
2238        &self.image_sha256
2239    }
2240
2241    /// Replace this console's persistent identity ([`Self::rom_sha256`]) with
2242    /// `sha256`, for a host that boots a game from a SAVED copy of its image.
2243    ///
2244    /// The case it exists for is a Famicom Disk System disk. FDS games save by
2245    /// writing to the disk, so a host persists the written image
2246    /// ([`Self::disk_image_bytes`]) and boots from THAT file on the next
2247    /// launch, which is how a disk game's progress carries over. Every
2248    /// constructor hashes the bytes it is given, so from the game's first disk
2249    /// save on, an `Nes` booted that way reported the MODIFIED disk's hash, and
2250    /// everything keyed on [`Self::rom_sha256`] changed identity with it:
2251    /// save-state slots and the `.rns` ROM tag ([`Self::rom_hash_tag`]),
2252    /// cheats, movies and `TAStudio`, netplay's ROM match, the HD-pack and
2253    /// per-game keys, the RA progress sidecar (v2.9.9 re-audit NF-17). The
2254    /// host knows the pristine image's hash (it keys the saved disk with it),
2255    /// so it passes that here, right after construction, and the console then
2256    /// reports the identity of the game rather than of one of its saves.
2257    ///
2258    /// Only the identity moves. [`Self::image_sha256`] stays the hash of the
2259    /// bytes actually loaded: its one consumer is the Vs. System database's
2260    /// whole-file fallback, which must describe the image in hand (and a disk
2261    /// never matches it). Emulation is untouched: nothing in the chips reads
2262    /// the identity.
2263    ///
2264    /// A setter rather than a constructor variant because the identity is the
2265    /// one field the host has to supply, and each image kind already has two
2266    /// constructors (with and without a sample rate); a setter covers all of
2267    /// them, including a host that builds the console through `Emu`. It is the
2268    /// host's word: the core cannot check that `sha256` belongs to an earlier
2269    /// state of the loaded image. Call it before anything is keyed on the
2270    /// identity (a movie recorded before the call stamps the old one).
2271    pub const fn set_rom_identity(&mut self, sha256: [u8; 32]) {
2272        self.rom_sha256 = sha256;
2273    }
2274
2275    /// Truncated ROM hash tag stored in the save-state header.
2276    #[must_use]
2277    pub fn rom_hash_tag(&self) -> [u8; ROM_HASH_TAG_LEN] {
2278        let mut t = [0u8; ROM_HASH_TAG_LEN];
2279        t.copy_from_slice(&self.rom_sha256[..ROM_HASH_TAG_LEN]);
2280        t
2281    }
2282
2283    /// Encode the entire emulator state into a `.rns` snapshot blob.
2284    ///
2285    /// Includes a versioned container header and the four chip + bus
2286    /// sections (`CPU `, `PPU `, `APU `, `MAP `, `BUS `), plus an optional
2287    /// `THM ` thumbnail section (128x120 RGBA8 nearest-neighbor downsample
2288    /// of the current framebuffer). The thumbnail is for UI slot pickers
2289    /// only -- per ADR 0003 it is NOT part of the deterministic save-state
2290    /// contract.
2291    #[must_use]
2292    pub fn snapshot(&self) -> Vec<u8> {
2293        let tag = self.rom_hash_tag();
2294        // The bus knows how to emit BUS / PPU / APU / MAP sections; we
2295        // splice the CPU section in at the end.
2296        let mut out = self.bus.snapshot(tag);
2297        let cpu_body = self.cpu.snapshot();
2298        save_state::write_section(
2299            &mut out,
2300            save_state::tag::CPU,
2301            rustynes_cpu::CPU_SNAPSHOT_VERSION,
2302            &cpu_body,
2303        );
2304        // Optional thumbnail. Body layout: width(u16 le) + height(u16 le) +
2305        // length(u32 le) + raw RGBA8. The fixed THUMBNAIL_LEN is what we
2306        // emit but the body carries the dimensions explicitly so future
2307        // bumps (different thumbnail sizes) can be detected by the reader.
2308        let thumb = self.thumbnail();
2309        let mut body = Vec::with_capacity(2 + 2 + 4 + save_state::THUMBNAIL_LEN);
2310        body.extend_from_slice(
2311            &u16::try_from(save_state::THUMBNAIL_WIDTH)
2312                .unwrap()
2313                .to_le_bytes(),
2314        );
2315        body.extend_from_slice(
2316            &u16::try_from(save_state::THUMBNAIL_HEIGHT)
2317                .unwrap()
2318                .to_le_bytes(),
2319        );
2320        body.extend_from_slice(&u32::try_from(thumb.len()).unwrap().to_le_bytes());
2321        body.extend_from_slice(&thumb);
2322        save_state::write_section(
2323            &mut out,
2324            save_state::tag::THM,
2325            save_state::THUMBNAIL_VERSION,
2326            &body,
2327        );
2328        out
2329    }
2330
2331    /// v2.8.0 Phase 3 — [`Self::snapshot`] minus the `THM ` thumbnail
2332    /// section, encoded into a caller-owned reused buffer. The fast path
2333    /// for per-frame consumers (run-ahead, the netplay save-state ring):
2334    /// no allocation in steady state and no 61 KiB thumbnail build. The
2335    /// output parses with [`Self::restore`] / [`Self::restore_quiet`]
2336    /// exactly like a full snapshot (`THM ` is optional by format).
2337    pub fn snapshot_core_into(&self, out: &mut Vec<u8>) {
2338        let tag = self.rom_hash_tag();
2339        self.bus.snapshot_into(out, tag);
2340        let cpu_body = self.cpu.snapshot();
2341        save_state::write_section(
2342            out,
2343            save_state::tag::CPU,
2344            rustynes_cpu::CPU_SNAPSHOT_VERSION,
2345            &cpu_body,
2346        );
2347    }
2348
2349    /// v2.3.3 — [`Self::snapshot_core_into`] with the PPU encoded **slim**
2350    /// (no framebuffer), for the rewind ring.
2351    ///
2352    /// The ring snapshots on every frame inside the frame budget and then XORs
2353    /// and LZ4-compresses the result. The framebuffer is 245,760 of the ~250 KB
2354    /// and is the worst possible payload for that scheme, since it changes
2355    /// every frame: the XOR never zeroes and the delta never compresses.
2356    /// Measured, that made rewind roughly double the produce-interval p95 and
2357    /// it was the cause of a user-visible judder report — see
2358    /// `docs/performance.md` v2.3.3 F3/F4.
2359    ///
2360    /// A blob written here restores every field except the framebuffer, so the
2361    /// caller must regenerate the image; [`Self::rewind_step_back`] runs one
2362    /// frame to do exactly that.
2363    pub fn snapshot_core_into_slim(&self, out: &mut Vec<u8>) {
2364        let tag = self.rom_hash_tag();
2365        self.bus.snapshot_into_slim(out, tag);
2366        let cpu_body = self.cpu.snapshot();
2367        save_state::write_section(
2368            out,
2369            save_state::tag::CPU,
2370            rustynes_cpu::CPU_SNAPSHOT_VERSION,
2371            &cpu_body,
2372        );
2373    }
2374
2375    /// Generate a 128x120 RGBA8 thumbnail of the current framebuffer.
2376    ///
2377    /// Nearest-neighbor downsample (sample every 2nd pixel of every 2nd row).
2378    /// The 1/4-resolution result is small enough that storing it in slot
2379    /// files is cheap (61,440 bytes uncompressed, ~10-20 KiB after the
2380    /// LZ4 path the rewind ring uses if it is ever wired through there).
2381    ///
2382    /// Per ADR 0003: NOT part of the deterministic save-state contract.
2383    /// Different builds may produce different pixel-perfect framebuffers
2384    /// at the same cycle if post-pass filters change.
2385    #[must_use]
2386    pub fn thumbnail(&self) -> Vec<u8> {
2387        // Native NES framebuffer is 256x240 RGBA8 = 245,760 bytes. Source
2388        // stride is 256 * 4 = 1024 bytes.
2389        const SRC_W: usize = 256;
2390        let fb = self.bus.framebuffer();
2391        let mut out = Vec::with_capacity(save_state::THUMBNAIL_LEN);
2392        for ty in 0..save_state::THUMBNAIL_HEIGHT {
2393            let sy = ty * 2;
2394            for tx in 0..save_state::THUMBNAIL_WIDTH {
2395                let sx = tx * 2;
2396                let i = (sy * SRC_W + sx) * 4;
2397                // Source framebuffer is always at least 256*240*4 bytes
2398                // (allocated by Ppu::new), so this index is in-bounds.
2399                out.extend_from_slice(&fb[i..i + 4]);
2400            }
2401        }
2402        debug_assert_eq!(out.len(), save_state::THUMBNAIL_LEN);
2403        out
2404    }
2405
2406    /// Extract a thumbnail from an `.rns` save-state blob without restoring
2407    /// it. Used by frontends to populate slot pickers.
2408    ///
2409    /// Returns `Ok(None)` if the blob is well-formed but contains no
2410    /// thumbnail section (older v0.9.0 slot files).
2411    ///
2412    /// # Errors
2413    ///
2414    /// Returns [`SnapshotError`] when the container header is malformed.
2415    pub fn extract_thumbnail(data: &[u8]) -> Result<Option<Vec<u8>>, SnapshotError> {
2416        let (_h, body_off) = save_state::parse_header(data)?;
2417        for s in save_state::SectionIter::new(&data[body_off..]) {
2418            let s = s?;
2419            if s.tag == save_state::tag::THM {
2420                // Body: width(u16) + height(u16) + length(u32) + bytes.
2421                if s.body.len() < 8 {
2422                    continue;
2423                }
2424                let w = u16::from_le_bytes([s.body[0], s.body[1]]) as usize;
2425                let h = u16::from_le_bytes([s.body[2], s.body[3]]) as usize;
2426                let n = u32::from_le_bytes([s.body[4], s.body[5], s.body[6], s.body[7]]) as usize;
2427                // Sanity: dimensions match what we currently emit, and the
2428                // declared length matches the body suffix.
2429                if w != save_state::THUMBNAIL_WIDTH
2430                    || h != save_state::THUMBNAIL_HEIGHT
2431                    || n != save_state::THUMBNAIL_LEN
2432                    || s.body.len() < 8 + n
2433                {
2434                    continue;
2435                }
2436                return Ok(Some(s.body[8..8 + n].to_vec()));
2437            }
2438        }
2439        Ok(None)
2440    }
2441
2442    /// Apply a previously [`Self::snapshot`]ed blob.
2443    ///
2444    /// Loading from a different ROM is allowed (the embedded hash tag is
2445    /// only a sanity check), but the result is undefined unless the chip
2446    /// section bodies are appropriate for the running mapper.
2447    ///
2448    /// The header's ROM hash tag is NOT compared with the running ROM, by
2449    /// decision (v2.9.0 re-audit NL-01): the paragraph above has always
2450    /// allowed a load from a different ROM. (The decision first rested on a
2451    /// second reason too, that the tag hashed the whole file including its
2452    /// iNES header, so a header-only correction of the same dump would have
2453    /// refused every state. Since v2.9.8 the tag is the leading bytes of
2454    /// [`Self::rom_sha256`], which leaves the header out, so that reason no
2455    /// longer applies; the first still does.) The section-level validation is what keeps a foreign state from
2456    /// crashing the core; see `docs/frontend.md` § "Save state files".
2457    ///
2458    /// # Errors
2459    ///
2460    /// Returns [`SnapshotError`] for malformed inputs; the machine is then
2461    /// unchanged.
2462    pub fn restore(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
2463        self.restore_inner(data, true)
2464    }
2465
2466    /// Shared restore body; `clear_rewind` distinguishes user-driven loads
2467    /// ([`Self::restore`] — the ring is invalidated) from same-timeline
2468    /// machine restores ([`Self::restore_quiet`] — the ring stays).
2469    fn restore_inner(&mut self, data: &[u8], clear_rewind: bool) -> Result<(), SnapshotError> {
2470        // v2.4.0 item B — a timeline jump, but only when this is a LOUD restore.
2471        //
2472        // `clear_rewind` already draws exactly the distinction the counter needs,
2473        // so it is reused rather than duplicated: `true` means a user-driven load
2474        // that invalidates the rewind history, `false` means a same-timeline
2475        // machine-driven restore (run-ahead's per-frame rollback, netplay's
2476        // rollback-resimulate) where the history stays valid.
2477        //
2478        // A same-timeline restore must NOT bump. That is the same rule the
2479        // provenance stash follows from the other direction — every same-timeline
2480        // restore has to carry the state that lives outside the save state — and
2481        // getting it wrong here would clear a consumer's telemetry sixty times a
2482        // second under run-ahead, which is worse than the stale-telemetry defect
2483        // this counter exists to fix.
2484        //
2485        // Bumped BEFORE the restore can fail, deliberately. Before v2.7.4 a
2486        // failed restore could leave the machine partially applied, which is a
2487        // discontinuity whether or not it completed. A loud restore now rolls
2488        // back on failure (below), so a failed one leaves the timeline intact
2489        // and the bump costs a consumer one telemetry reset; keeping it
2490        // unconditional keeps this counter's rule simple.
2491        if clear_rewind {
2492            self.timeline_generation = self.timeline_generation.wrapping_add(1);
2493        }
2494        // v2.7.4 (frontend audit MOB-08) — a load is all-or-nothing. The stages
2495        // in `apply_snapshot` mutate as they go (the bus sections first, then
2496        // the CPU, then the clock check), so a blob rejected at a later stage
2497        // used to leave the earlier stages from the blob and the rest from the
2498        // running game: a machine that was neither, which the next frame
2499        // emulated. So the running machine is snapshotted first and put back
2500        // on failure.
2501        //
2502        // v2.9.0 (re-audit NC-03 / NL-01) — on BOTH paths. v2.7.4 skipped the
2503        // backup for quiet restores, on the stated premise that they "only ever
2504        // restore snapshots this core just wrote, which cannot fail these
2505        // checks". That premise was false: the libretro core routes EVERY
2506        // `retro_unserialize` through `restore_quiet` — a user's Load State, a
2507        // state written by an older core, a netplay peer's state, a truncated
2508        // or corrupt file — and the re-audit demonstrated a rejected CPU
2509        // section leaving the bus, PPU, APU and mapper from the rejected file
2510        // under the running game's CPU. The premise is also unenforceable: the
2511        // quiet/loud split is about the REWIND RING (same timeline or not), and
2512        // nothing about that choice says the bytes are trusted.
2513        //
2514        // The backup is the THM-less `snapshot_core_into` (the thumbnail is
2515        // ignored on restore) into a buffer pooled on the `Nes`, so the hot
2516        // path — run-ahead's and netplay's per-frame rollbacks — pays one
2517        // state copy and no allocation for the buffer in steady state. The
2518        // measured cost is recorded in `docs/frontend.md` § "Save state files".
2519        let mut backup = core::mem::take(&mut self.restore_backup);
2520        self.snapshot_core_into(&mut backup);
2521        let applied = self.apply_snapshot(data);
2522        if applied.is_err() {
2523            // Our own fresh snapshot always restores (the round-trip
2524            // invariant every save-state test pins); if it somehow did not,
2525            // the original error is still the one worth reporting. The core
2526            // has no logger (`no_std`), so the invariant is asserted in debug
2527            // and test builds rather than silently assumed.
2528            //
2529            // The invariant needs every value a running machine can hold to
2530            // pass the validators. v2.9.9's re-audit (NC-09) found one that
2531            // did not: a restored APU filter value accepted as merely finite
2532            // overflowed to NaN, after which this backup's own APU section was
2533            // refused part-way through the rollback. The validator now bounds
2534            // that state, so the backup is again a state the core produced.
2535            let rolled_back = self.apply_snapshot(&backup);
2536            debug_assert!(
2537                rolled_back.is_ok(),
2538                "rolling back to this core's own snapshot failed: {rolled_back:?}"
2539            );
2540        }
2541        self.restore_backup = backup;
2542        applied?;
2543        // Loading invalidates the rewind ring (the new state is unrelated
2544        // to what was buffered before).
2545        if clear_rewind && let Some(r) = &mut self.rewind {
2546            r.clear();
2547        }
2548        // v2.3.2 "Lucid" — and it invalidates write attribution for the same
2549        // reason, on BOTH restore paths. The restored bytes were not written by
2550        // any instruction this session executed, so the PCs recorded against
2551        // those offsets describe a timeline that no longer exists. Reporting
2552        // them would be a confidently wrong answer; reporting nothing until the
2553        // program writes again is the honest one.
2554        //
2555        // v2.3.6 CORRECTION. This comment used to end by claiming that under
2556        // run-ahead the clear "fires once per displayed frame, leaving exactly
2557        // the visible frame's writes — which is the timeline the user is looking
2558        // at". That was false about the two lines below it, which empty both
2559        // stores completely; and because run-ahead's rollback is the LAST thing
2560        // before the frontend releases the emulator lock, the wipe landed on the
2561        // visible frame's records before any UI could read them. The shipped
2562        // Pixel Provenance inspector therefore rendered an empty report for every
2563        // user with the default `run_ahead = 1`. The clear here is right and
2564        // stays; run-ahead now carries the stores AROUND it (`RunAhead::finish`
2565        // → `Nes::take_provenance` / `put_provenance`), which is what this
2566        // comment always claimed was happening.
2567        //
2568        // The per-pixel provenance frame is cleared for the same reason, and it
2569        // needs saying separately because the obvious analogy is wrong: the
2570        // framebuffer IS serialized and comes back consistent with the restored
2571        // state, while this frame is not. A restore landing mid-frame would
2572        // otherwise leave pre-restore tile and palette addresses for every pixel
2573        // above the current scanline, unmarked. (Review catch on PR #356.)
2574        #[cfg(feature = "debug-hooks")]
2575        {
2576            self.bus.ppu.clear_write_attribution();
2577            self.bus.ppu.clear_pixel_provenance();
2578            // v2.3.7 — the audio register attribution is the same kind of claim
2579            // about the same replaced timeline: a restored state's APU registers
2580            // were not written by any instruction this session executed, so
2581            // keeping their PCs would report a timeline that no longer exists.
2582            //
2583            // This was MISSING when audio provenance first landed, while
2584            // `docs/audio-provenance.md` already asserted that "save-state loads
2585            // and netplay rollback still clear" — prose describing behaviour the
2586            // code did not have, which is the exact failure that let Pixel
2587            // Provenance ship broken for four releases. Caught in review.
2588            //
2589            // Harmless for run-ahead: `RunAhead::finish` TAKES the store before
2590            // `restore_quiet` and puts it back after, so `audio_prov` is `None`
2591            // here and this call is a no-op on that path.
2592            self.bus.apu.clear_audio_provenance_history();
2593        }
2594        Ok(())
2595    }
2596
2597    /// The mutating stages of a restore: the bus sections, then the CPU
2598    /// section, then the cross-section clock check. Not atomic by itself; see
2599    /// the backup in [`Self::restore_inner`], which both public restore paths
2600    /// go through.
2601    fn apply_snapshot(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
2602        // Restore bus first — it consumes BUS / PPU / APU / MAP sections.
2603        self.bus.restore(data)?;
2604        // Then walk the sections again to find the CPU body.
2605        let (_h, body_off) = save_state::parse_header(data)?;
2606        let mut saw_cpu = false;
2607        for s in save_state::SectionIter::new(&data[body_off..]) {
2608            let s = s?;
2609            if s.tag == save_state::tag::CPU {
2610                if s.version != rustynes_cpu::CPU_SNAPSHOT_VERSION {
2611                    return Err(SnapshotError::VersionMismatch {
2612                        tag: save_state::tag_string(s.tag),
2613                        file_version: s.version,
2614                        chip_supports: rustynes_cpu::CPU_SNAPSHOT_VERSION,
2615                    });
2616                }
2617                self.cpu
2618                    .restore(s.body)
2619                    .map_err(|e| SnapshotError::SectionInvalid {
2620                        tag: save_state::tag_string(s.tag),
2621                        reason: format!("{e}"),
2622                    })?;
2623                saw_cpu = true;
2624            }
2625        }
2626        if !saw_cpu {
2627            return Err(SnapshotError::MissingSection("CPU ".into()));
2628        }
2629        // v2.7.0 -- the one cross-section invariant: the CPU's master clock and
2630        // the bus's PPU clock must be close enough that the next catch-up
2631        // terminates (see `SystemBus::check_restored_clocks`).
2632        self.bus.check_restored_clocks(self.cpu.master_clock())?;
2633        Ok(())
2634    }
2635
2636    /// v2.8.0 Phase 3 — [`Self::restore`] WITHOUT clearing the rewind ring.
2637    ///
2638    /// For internal, machine-driven restores on the same timeline —
2639    /// run-ahead's per-frame rollback and netplay's rollback-resimulate —
2640    /// where the buffered rewind history remains exactly as valid as
2641    /// before. User-driven loads (save-state slots) keep using
2642    /// [`Self::restore`], which invalidates the ring.
2643    ///
2644    /// "Quiet" says nothing about whether the bytes are trusted: the libretro
2645    /// core routes every `retro_unserialize` here, including user state files.
2646    /// Since v2.9.0 a rejected blob therefore leaves the machine exactly as it
2647    /// was, on this path as on [`Self::restore`].
2648    ///
2649    /// # Errors
2650    ///
2651    /// Returns [`SnapshotError`] for malformed inputs; the machine is then
2652    /// unchanged.
2653    pub fn restore_quiet(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
2654        self.restore_inner(data, false)
2655    }
2656
2657    /// Enable the rewind ring buffer with default capacity (32 MiB) and
2658    /// keyframe period (60).
2659    pub fn enable_rewind(&mut self) {
2660        self.enable_rewind_with(REWIND_DEFAULT_MAX_BYTES, REWIND_DEFAULT_KEYFRAME_PERIOD);
2661    }
2662
2663    /// Enable rewind with explicit byte budget + keyframe period.
2664    pub fn enable_rewind_with(&mut self, max_bytes: usize, keyframe_period: u32) {
2665        self.rewind = Some(RewindRing::new(max_bytes, keyframe_period));
2666    }
2667
2668    /// Disable rewind and free the buffer.
2669    pub fn disable_rewind(&mut self) {
2670        self.rewind = None;
2671    }
2672
2673    /// Enable the per-CPU-instruction boot trace fixture with the given
2674    /// [`CpuBootTrace`](crate::cpu_boot_trace::CpuBootTrace).  Records past
2675    /// the trace's capacity are silently dropped (see
2676    /// [`CpuBootTrace::overflow`](crate::cpu_boot_trace::CpuBootTrace::overflow)).
2677    /// See `crates/rustynes-core/src/cpu_boot_trace.rs` for usage.
2678    #[cfg(feature = "cpu-boot-trace")]
2679    pub fn enable_cpu_boot_trace(&mut self, trace: crate::cpu_boot_trace::CpuBootTrace) {
2680        self.cpu_boot_trace = Some(trace);
2681    }
2682
2683    /// Take the accumulated CPU boot trace, leaving the slot empty.
2684    /// Returns `None` if tracing was never enabled.
2685    #[cfg(feature = "cpu-boot-trace")]
2686    #[must_use]
2687    pub const fn take_cpu_boot_trace(&mut self) -> Option<crate::cpu_boot_trace::CpuBootTrace> {
2688        self.cpu_boot_trace.take()
2689    }
2690
2691    /// Borrow the in-flight CPU boot trace for inspection.
2692    #[cfg(feature = "cpu-boot-trace")]
2693    #[must_use]
2694    pub const fn cpu_boot_trace(&self) -> Option<&crate::cpu_boot_trace::CpuBootTrace> {
2695        self.cpu_boot_trace.as_ref()
2696    }
2697
2698    /// Snapshot the current `(CPU register file + bus cycle + PPU
2699    /// position + opcode preview)` tuple into the CPU boot trace.
2700    ///
2701    /// Called from `run_frame` / `step_instruction` BEFORE the
2702    /// `Cpu::step` call.  The opcode + 2 operand bytes are peeked
2703    /// side-effect-free via `SystemBus::debug_peek_cpu` so the
2704    /// trace is non-perturbing.
2705    ///
2706    /// No-op if the trace was never enabled.
2707    #[cfg(feature = "cpu-boot-trace")]
2708    fn cpu_boot_trace_record(&mut self) {
2709        use crate::cpu_boot_trace::CpuBootRecord;
2710        let Some(trace) = self.cpu_boot_trace.as_mut() else {
2711            return;
2712        };
2713        let cycle = self.bus.cycle();
2714        // Range pre-check: skip the peek bookkeeping entirely if this
2715        // cycle is outside the configured window.  The trace's own
2716        // `maybe_push` re-checks; the pre-check is the hot-path
2717        // optimisation.
2718        if !trace.config().contains(cycle) {
2719            return;
2720        }
2721        let pc = self.cpu.pc;
2722        let opcode = self.bus.debug_peek_cpu(pc);
2723        let op1 = self.bus.debug_peek_cpu(pc.wrapping_add(1));
2724        let op2 = self.bus.debug_peek_cpu(pc.wrapping_add(2));
2725        let ppu = self.bus.ppu();
2726        let mut flags: u8 = 0;
2727        // Mesen2 exposes `cpu.nmiFlag` and `cpu.irqFlag` (its
2728        // own pending-interrupt latches) but not the
2729        // armed-vs-pending distinction; flag bit 0 means "PPU is
2730        // driving NMI line high" which is observable on both
2731        // sides at instruction-fetch boundary.
2732        if ppu.nmi_line() {
2733            flags |= 0x01;
2734        }
2735        let rec = CpuBootRecord {
2736            cycle,
2737            frame: u32::try_from(ppu.frame()).unwrap_or(u32::MAX),
2738            scanline: ppu.scanline(),
2739            dot: ppu.dot(),
2740            pc,
2741            a: self.cpu.a,
2742            x: self.cpu.x,
2743            y: self.cpu.y,
2744            p: self.cpu.p.bits(),
2745            s: self.cpu.s,
2746            opcode,
2747            op1,
2748            op2,
2749            flags,
2750        };
2751        trace.maybe_push(rec);
2752    }
2753
2754    /// Push the current state onto the rewind ring. Frontends call this
2755    /// at the end of each completed frame.
2756    ///
2757    /// No-op if rewind is disabled.
2758    pub fn rewind_capture(&mut self) {
2759        if self.rewind.is_none() {
2760            return;
2761        }
2762        let frame = self.bus.ppu().frame();
2763        // v2.8.0 Phase 3 — the core fast path: no THM thumbnail (the ring
2764        // is never shown in a slot picker) and a reused buffer instead of
2765        // a fresh ~320 KiB allocation per frame. The ring still LZ4s /
2766        // delta-encodes the bytes itself.
2767        let mut buf = core::mem::take(&mut self.rewind_snap_buf);
2768        // v2.3.3 — SLIM: omit the framebuffer. See `snapshot_core_into_slim`.
2769        self.snapshot_core_into_slim(&mut buf);
2770        if let Some(ring) = &mut self.rewind {
2771            ring.push(frame, &buf);
2772        }
2773        self.rewind_snap_buf = buf;
2774    }
2775
2776    /// Pop the most recent rewind entry and restore it. Returns `true` on
2777    /// success, `false` if the ring is empty (or rewind is disabled).
2778    pub fn rewind_step_back(&mut self) -> bool {
2779        let Some(ring) = self.rewind.as_mut() else {
2780            return false;
2781        };
2782        let Some(result) = ring.pop_back() else {
2783            return false;
2784        };
2785        let bytes = match result {
2786            Ok(b) => b,
2787            Err(_e) => return false,
2788        };
2789        // Restore but keep the ring alive (don't let `restore` clear it,
2790        // because the user is mid-rewind).
2791        let saved_ring = self.rewind.take();
2792        let r = self.restore(&bytes);
2793        // Reattach the (possibly cleared, but cleared-by-us is fine) ring.
2794        self.rewind = saved_ring;
2795        if r.is_err() {
2796            return false;
2797        }
2798        // v2.3.3 — ring entries are SLIM, so the restore above left the
2799        // framebuffer holding whatever was last displayed; without an image the
2800        // picture would freeze while the state rewound. Regenerate it WITHOUT
2801        // changing the state this call is contracted to land on:
2802        //
2803        //   1. state is at frame N (restored above), framebuffer stale
2804        //   2. run one frame  -> renders exactly frame N's image, state N+1
2805        //   3. restore the same bytes -> state back to N
2806        //
2807        // Step 3 is what makes this exact rather than approximate, and it works
2808        // *because* the blob is slim: a slim restore does not touch the
2809        // framebuffer, so the image rendered in step 2 survives. The observable
2810        // contract is unchanged from before v2.3.3 — `cycle()` lands on the
2811        // snapshotted frame — which the `rewind_step_back_returns_prior_frames`
2812        // harness test pins.
2813        //
2814        // Capture is suppressed across step 2, or stepping back would push the
2815        // frame it just rendered and the ring would never drain.
2816        let saved_capture = self.rewind_capture_enabled;
2817        self.rewind_capture_enabled = false;
2818        self.run_frame();
2819        let saved_ring = self.rewind.take();
2820        let re = self.restore(&bytes);
2821        self.rewind = saved_ring;
2822        self.rewind_capture_enabled = saved_capture;
2823        re.is_ok()
2824    }
2825
2826    /// Drop every buffered rewind entry. Called when the user releases
2827    /// the rewind key, so subsequent forward play overwrites — there's
2828    /// nothing to overwrite, but we want forward play to capture into a
2829    /// fresh ring rather than tail-of-old-history.
2830    pub fn rewind_clear(&mut self) {
2831        if let Some(r) = &mut self.rewind {
2832            r.clear();
2833        }
2834    }
2835
2836    /// `true` if rewind is enabled.
2837    #[must_use]
2838    pub const fn rewind_enabled(&self) -> bool {
2839        self.rewind.is_some()
2840    }
2841
2842    /// Number of buffered rewind entries.
2843    #[must_use]
2844    pub fn rewind_len(&self) -> usize {
2845        self.rewind.as_ref().map_or(0, RewindRing::len)
2846    }
2847
2848    /// Approximate memory used by the rewind ring, in bytes.
2849    #[must_use]
2850    pub fn rewind_bytes_used(&self) -> usize {
2851        self.rewind.as_ref().map_or(0, RewindRing::bytes_used)
2852    }
2853
2854    // -------------------------------------------------------------------
2855    // Debugger inspection API (Sprint 5-3). All read-only — these methods
2856    // MUST NOT advance emulator-visible state.
2857    // -------------------------------------------------------------------
2858
2859    /// Snapshot the CPU register file.
2860    #[must_use]
2861    #[allow(clippy::missing_const_for_fn)] // `is_jammed` is const-callable but we're const-conservative.
2862    pub fn cpu_snapshot(&self) -> CpuDebugView {
2863        let c = &self.cpu;
2864        CpuDebugView {
2865            a: c.a,
2866            x: c.x,
2867            y: c.y,
2868            s: c.s,
2869            pc: c.pc,
2870            p: c.p.bits(),
2871            jammed: c.is_jammed(),
2872            cycles: c.cycles,
2873        }
2874    }
2875
2876    /// Snapshot PPU state for the debugger.
2877    #[must_use]
2878    #[allow(clippy::missing_const_for_fn)]
2879    pub fn ppu_snapshot(&self) -> PpuDebugView {
2880        let ppu = self.bus.ppu();
2881        let regs = ppu.debug_registers();
2882        let (v, t, fine_x, w) = ppu.debug_scroll();
2883        PpuDebugView {
2884            dot: ppu.dot(),
2885            scanline: ppu.scanline(),
2886            frame: ppu.frame(),
2887            ctrl: regs[0],
2888            mask: regs[1],
2889            status: regs[2],
2890            oam_addr: regs[3],
2891            v,
2892            t,
2893            fine_x,
2894            w_toggle: w,
2895            sprite_size_16: ppu.sprite_size_16(),
2896            bg_pattern_base: ppu.bg_pattern_base(),
2897            sprite_pattern_base: ppu.sprite_pattern_base(),
2898            nmi_line: ppu.nmi_line(),
2899        }
2900    }
2901
2902    /// Snapshot APU channel outputs and IRQ flags.
2903    #[must_use]
2904    pub fn apu_snapshot(&self) -> ApuDebugView {
2905        let apu = self.bus.apu();
2906        ApuDebugView {
2907            pulse1: apu.pulse1_out(),
2908            pulse2: apu.pulse2_out(),
2909            triangle: apu.triangle_out(),
2910            noise: apu.noise_out(),
2911            dmc: apu.dmc_out(),
2912            external: apu.external_out(),
2913            frame_irq: apu.frame_irq_pending(),
2914            dmc_irq: apu.dmc_irq_pending(),
2915        }
2916    }
2917
2918    /// Set the APU per-channel enable mask (a UI playback overlay, NOT NES
2919    /// hardware state). Bit 0 = pulse 1, 1 = pulse 2, 2 = triangle, 3 = noise,
2920    /// 4 = DMC, 5 = external/mapper audio. A cleared bit mutes that channel.
2921    ///
2922    /// The default ([`rustynes_apu::CHANNEL_MASK_ALL`]) is byte-identical to
2923    /// the un-masked mixer — the deterministic per-frame audio is unchanged
2924    /// unless the frontend explicitly mutes a channel. This is never written
2925    /// into the save state, so it never affects determinism or round-trips.
2926    pub const fn set_apu_channel_mask(&mut self, mask: u8) {
2927        self.bus.apu_mut().set_channel_mask(mask);
2928    }
2929
2930    /// Current APU per-channel enable mask. See [`Self::set_apu_channel_mask`].
2931    #[must_use]
2932    pub const fn apu_channel_mask(&self) -> u8 {
2933        self.bus.apu().channel_mask()
2934    }
2935
2936    /// v1.4.0 Workstream C — set the APU per-channel output gain (a UI mixing
2937    /// overlay, NOT NES hardware state), generalizing [`Self::set_apu_channel_mask`].
2938    /// Index 0 = pulse 1, 1 = pulse 2, 2 = triangle, 3 = noise, 4 = DMC,
2939    /// 5 = external/mapper audio. Each gain is clamped to `0.0..=2.0`.
2940    ///
2941    /// The default ([`rustynes_apu::CHANNEL_GAIN_UNITY`], all `1.0`) is
2942    /// byte-identical to the un-scaled mixer — the deterministic per-frame audio
2943    /// is unchanged unless the frontend explicitly changes a gain. Never written
2944    /// into the save state, so it never affects determinism or round-trips.
2945    pub fn set_apu_channel_gain(&mut self, gain: [f32; 6]) {
2946        self.bus.apu_mut().set_channel_gain(gain);
2947    }
2948
2949    /// v2.1.3 — select the APU analog output-filter model (see
2950    /// [`rustynes_apu::FilterModel`]). The default
2951    /// [`rustynes_apu::FilterModel::NesRf`] (NES front-loader: 90 + 440 Hz HPF +
2952    /// 14 kHz LPF) is byte-identical to the pre-v2.1.3 output; `Famicom` (37 Hz
2953    /// HPF) and `Clean` (~10 Hz DC-block) drop the aggressive 440 Hz high-pass
2954    /// for a fuller low end. Tonal only — channel content is unchanged, and the
2955    /// model is never written into the save state (a frontend/config concern,
2956    /// re-applied at load), so determinism and round-trips are unaffected.
2957    pub fn set_apu_filter_model(&mut self, model: rustynes_apu::FilterModel) {
2958        self.bus.apu_mut().set_filter_model(model);
2959    }
2960
2961    /// v2.9.8 — the APU analog output-filter model last selected with
2962    /// [`Self::set_apu_filter_model`] (the default until one is). Survives a
2963    /// [`Self::power_cycle`].
2964    #[must_use]
2965    pub const fn apu_filter_model(&self) -> rustynes_apu::FilterModel {
2966        self.bus.apu().filter_model()
2967    }
2968
2969    /// Current APU per-channel output gain. See [`Self::set_apu_channel_gain`].
2970    #[must_use]
2971    pub const fn apu_channel_gain(&self) -> [f32; 6] {
2972        self.bus.apu().channel_gain()
2973    }
2974
2975    /// Borrow OAM (256 bytes = 64 sprites x 4 bytes).
2976    ///
2977    /// Returns a cloned `[u8; 256]` so the caller doesn't have to manage
2978    /// a borrow lifetime against `&self`.
2979    #[must_use]
2980    pub fn oam(&self) -> [u8; 256] {
2981        let mut out = [0u8; 256];
2982        let oam = self.bus.ppu().oam();
2983        out.copy_from_slice(&oam[..256]);
2984        out
2985    }
2986
2987    /// One OAM byte (`index` = `0..=255`), without copying the whole 256-byte
2988    /// array — for single-byte readers (e.g. the Lua `memory:read_oam`) that
2989    /// would otherwise pay a full `oam()` copy per access. Read-only.
2990    #[must_use]
2991    pub fn oam_byte(&self, index: u8) -> u8 {
2992        self.bus.ppu().oam()[index as usize]
2993    }
2994
2995    /// Borrow palette RAM (32 bytes).
2996    #[must_use]
2997    pub const fn palette_ram(&self) -> [u8; 32] {
2998        *self.bus.ppu().palette_ram()
2999    }
3000
3001    /// v1.1.0 beta.1 — install (`Some`) or clear (`None`) a custom 64-entry base
3002    /// palette loaded from a `.pal` file. A frontend presentation override: it
3003    /// re-tints the displayed RGBA framebuffer via the PPU's colour LUT but does
3004    /// not touch any logical core state. `None` (the default) is byte-identical to
3005    /// the built-in palette, so `AccuracyCoin` + the commercial oracle (which never
3006    /// set one) are unaffected. Not part of the save-state.
3007    pub const fn set_custom_palette(&mut self, base: Option<[[u8; 3]; 64]>) {
3008        self.bus.set_custom_palette(base);
3009    }
3010
3011    /// v2.9.8 — the custom base palette installed by
3012    /// [`Self::set_custom_palette`], or `None` for the built-in one. Survives a
3013    /// [`Self::power_cycle`].
3014    #[must_use]
3015    pub const fn custom_palette(&self) -> Option<[[u8; 3]; 64]> {
3016        self.bus.ppu().custom_palette()
3017    }
3018
3019    /// v1.7.0 "Forge" Workstream F3 — set the PPU extra-scanlines overclock: the
3020    /// number of EXTRA idle vblank scanlines the PPU inserts per frame (at the
3021    /// existing dot resolution, Mesen2 `UpdateTimings`). Each extra line is pure
3022    /// additional CPU run-time — it renders nothing, sets/clears no PPU flag, and
3023    /// fires no VBL/NMI/A12 event, so the visible image is unchanged. `0` (the
3024    /// default) is **byte-identical** to stock NES timing — `AccuracyCoin`, the
3025    /// commercial oracle, and nestest (which never set it) are unaffected.
3026    /// **Off by default**; a frontend config knob, not part of the save-state.
3027    /// Distinct from the CPU-multiplier overclock ([`Self::set_cpu_overclock`],
3028    /// v3.1.0), which shortens the CPU cycle instead of lengthening the frame.
3029    ///
3030    /// Clamped to [`MAX_EXTRA_SCANLINES`] (v2.9.9, NC-11): the cap used to
3031    /// live only in the desktop frontend, so a movie's options record could
3032    /// set 65,535 lines and leave every `run_frame` ending on its cycle
3033    /// budget with no frame.
3034    pub const fn set_extra_scanlines(&mut self, lines: u16) {
3035        let lines = if lines > MAX_EXTRA_SCANLINES {
3036            MAX_EXTRA_SCANLINES
3037        } else {
3038            lines
3039        };
3040        self.bus.set_extra_scanlines(lines);
3041    }
3042
3043    /// v3.1.0 (`T-CPU-OVERCLOCK`, FE-01) — set the CPU-multiplier overclock:
3044    /// the CPU runs `k` times faster against an unchanged PPU, so a game gets
3045    /// `k` times the CPU time per frame.
3046    ///
3047    /// It splits the region's master-clock CPU divider into `k` cycles whose
3048    /// lengths sum to exactly one stock cycle (NTSC 12 -> 6 / 4 / 3; PAL 16 at
3049    /// `x3` is 5, 5, 6 and Dendy 15 at `x4` is 3, 4, 4, 4), so the multiplier is
3050    /// exact on every region. Until the v3.1.0 review it divided with integer
3051    /// division, which made PAL `x3` run x3.2 and Dendy `x4` x5. The APU, the
3052    /// DMC, every mapper's CPU-cycle hook (the VRC / FME-7 / N163 IRQ
3053    /// counters) and the PPU's open-bus and post-reset timers stay at the
3054    /// STOCK rate, so the pitch, the music tempo and the cycle-timed raster
3055    /// IRQs are unchanged: what speeds up is the game's own code. DMA follows
3056    /// the APU's get/put phase, so a DMA takes about `k` times as many CPU
3057    /// cycles and the same real time.
3058    ///
3059    /// `1` (the default) is stock and byte-identical to a build without the
3060    /// option. `0` is treated as `1`, and values above [`MAX_CPU_OVERCLOCK`]
3061    /// clamp to it. Configuration, not save-state: the host re-applies it,
3062    /// and movies and netplay carry it in [`crate::HardwareOptions`]. Not
3063    /// hardware behaviour: no real console runs its CPU faster than its APU.
3064    pub const fn set_cpu_overclock(&mut self, k: u8) {
3065        let k = if k == 0 {
3066            1
3067        } else if k > MAX_CPU_OVERCLOCK {
3068            MAX_CPU_OVERCLOCK
3069        } else {
3070            k
3071        };
3072        self.bus.set_cpu_overclock(k);
3073    }
3074
3075    /// v3.1.0 — the CPU-multiplier overclock (`1` = stock).
3076    #[must_use]
3077    pub const fn cpu_overclock(&self) -> u8 {
3078        self.bus.cpu_overclock()
3079    }
3080
3081    /// v3.1.0 (`T-SPRITE-LIMIT`, FE-02) — draw the sprites beyond the eighth
3082    /// on a scanline, removing sprite flicker.
3083    ///
3084    /// **Render-only.** Sprite evaluation, secondary OAM, the overflow flag,
3085    /// sprite-0 hit and every real sprite fetch (with its A12 edges, which an
3086    /// MMC3 counts) are exactly stock, so the game sees no difference; only
3087    /// the picture does. The extra sprites draw behind all eight hardware ones.
3088    /// On the boards whose CHR reads have an effect (MMC2, MMC4, the J.Y.
3089    /// ASIC, Bandai 96, Nanjing 163; `Mapper::chr_reads_are_pure`) the option
3090    /// draws eight, as stock, because the extra pattern reads would change
3091    /// emulation there.
3092    ///
3093    /// Off by default. Configuration, not save-state; carried across a power
3094    /// cycle, and in movies and netplay by [`crate::HardwareOptions`] (the
3095    /// picture differs, so a movie's frame hashes and a netplay peer's desync
3096    /// checks depend on it).
3097    pub const fn set_sprite_limit_disabled(&mut self, disabled: bool) {
3098        self.bus.set_sprite_limit_disabled(disabled);
3099    }
3100
3101    /// v3.1.0 — whether the sprites beyond the eighth are drawn.
3102    #[must_use]
3103    pub const fn sprite_limit_disabled(&self) -> bool {
3104        self.bus.sprite_limit_disabled()
3105    }
3106
3107    /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`, ACC-13) — run an MMC3 (mapper 4) under
3108    /// a chosen IRQ revision, or `None` for the one its header selects.
3109    ///
3110    /// The MMC3's two IRQ behaviours are mutually exclusive: the Sharp MMC3B
3111    /// / MMC3C (the default) asserts IRQ whenever a clock leaves the counter at
3112    /// 0 with IRQs enabled, so a latch of 0 fires every scanline. The MMC3A
3113    /// and non-Sharp MMC3B (`Mmc3Revision::Nec`) assert on a 1 -> 0 decrement
3114    /// and on a `$C001` reload to 0 (one IRQ per `$C001` write while `$C000`
3115    /// is 0, even if the counter was already 0), but not when the counter,
3116    /// already 0, reloads 0 by itself. A NES 2.0 header can say which (submapper 4); an iNES 1.0
3117    /// dump cannot, so this override is how a player runs a game, or blargg's
3118    /// `mmc3_test_2/6-MMC3_alt`, on the other chip. The default (`None`) is
3119    /// unchanged. Configuration, re-applied when a power cycle rebuilds the
3120    /// board, and carried in [`crate::HardwareOptions`]. Returns whether the
3121    /// board is one that applies it.
3122    pub fn set_mmc3_revision_override(
3123        &mut self,
3124        revision: Option<rustynes_mappers::Mmc3Revision>,
3125    ) -> bool {
3126        self.bus.set_mmc3_revision_override(revision)
3127    }
3128
3129    /// v3.1.0 — the forced MMC3 IRQ revision (`None` = the header's).
3130    #[must_use]
3131    pub const fn mmc3_revision_override(&self) -> Option<rustynes_mappers::Mmc3Revision> {
3132        self.bus.mmc3_revision_override()
3133    }
3134
3135    /// v1.7.0 F3 — the configured extra-scanline overclock count (`0` = stock).
3136    #[must_use]
3137    pub const fn extra_scanlines(&self) -> u16 {
3138        self.bus.extra_scanlines()
3139    }
3140
3141    /// v2.1.8 A1 — enable or disable the specialized visible-scanline fast dot
3142    /// path (a pure performance optimization for the PPU's hottest per-dot
3143    /// case).
3144    ///
3145    /// The PPU dot FSM (`Ppu::tick`) is the emulator's single hottest function
3146    /// (~46% of a representative frame's self-time). This knob dispatches the
3147    /// common "clean" visible BG-render dots (visible scanline, dots `1..=256`,
3148    /// rendering stably enabled, no sub-dot disturbance) to a straight-line
3149    /// handler that runs the identical helper sequence with the statically-dead
3150    /// event branches pruned. **On by default since v2.2.3** (OFF through
3151    /// v2.2.2) and **byte-identical** to the exact path — proven bit-for-bit
3152    /// every frame by the differential test (`fast_dotloop_diff`) and the full
3153    /// `AccuracyCoin` / visual-regression / nestest oracle, and measured at
3154    /// **-11.3%** on the rendering-heavy `full_frame` bench. Setting it `false`
3155    /// selects the fully-general per-dot path and remains the fallback. A
3156    /// frontend/config knob, NOT part of the save-state.
3157    pub const fn set_fast_dotloop(&mut self, enabled: bool) {
3158        self.bus.set_fast_dotloop(enabled);
3159    }
3160
3161    /// v2.1.8 A1 — whether the visible-scanline fast dot path is enabled
3162    /// (`true` = default since v2.2.3; both settings produce identical frames).
3163    #[must_use]
3164    pub const fn fast_dotloop(&self) -> bool {
3165        self.bus.fast_dotloop()
3166    }
3167
3168    /// v2.1.4 F2.3 — enable or disable the optional OAM-decay accuracy model.
3169    ///
3170    /// The 2C02's OAM is dynamic RAM refreshed by sprite evaluation; with rendering
3171    /// disabled for a while its un-refreshed 8-byte rows decay to a fixed garbage
3172    /// pattern. This models that (à la Mesen2's `EnableOamDecay`): a row un-touched
3173    /// for > 3000 CPU cycles decays on the next read. **Off by default** and
3174    /// **byte-identical** to a decay-free core when off — `AccuracyCoin`, the
3175    /// commercial oracle, and the visual regression suites are unaffected. It is
3176    /// NTSC/Dendy-only (PAL's refresh cadence masks decay). Deterministic when on
3177    /// (driven off the PPU's monotonic dot counter — no wall-clock / OS RNG), and a
3178    /// frontend/config knob re-applied on load, NOT part of the save-state (the
3179    /// in-flight per-row ages are serialized as a relative age so a rollback stays
3180    /// deterministic; the enable flag is not).
3181    pub const fn set_oam_decay(&mut self, enabled: bool) {
3182        self.bus.set_oam_decay(enabled);
3183    }
3184
3185    /// v2.1.4 F2.3 — whether the optional OAM-decay model is enabled (`false` =
3186    /// default, byte-identical to a decay-free core).
3187    #[must_use]
3188    pub const fn oam_decay_enabled(&self) -> bool {
3189        self.bus.oam_decay_enabled()
3190    }
3191
3192    /// v2.1.7 P5 — select the emulated 2C02 die revision (see [`PpuRevision`]).
3193    ///
3194    /// The [`PpuRevision::default`] ([`PpuRevision::Rp2c02H`]) models no extra
3195    /// quirks, so at the default this is inert and the core is **byte-identical**
3196    /// to a build without it — `AccuracyCoin`, the commercial oracle, and the
3197    /// visual / audio regression suites are unaffected. Selecting
3198    /// [`PpuRevision::Rp2c02G`] additionally models the OAMADDR (`$2003`)
3199    /// write-during-rendering OAM corruption glitch (*Huge Insect*). The
3200    /// selection is stored so a power-cycle re-applies it; it is config, not
3201    /// save-state (the corruption *state* it can arm already round-trips via the
3202    /// v6 PPU snapshot tail). Deterministic. A frontend/config knob re-applied on
3203    /// load, mirroring [`Nes::set_oam_decay`].
3204    pub const fn set_ppu_revision(&mut self, revision: PpuRevision) {
3205        self.bus.set_ppu_revision(revision);
3206    }
3207
3208    /// v2.1.7 P5 — the currently-selected 2C02 die revision (default
3209    /// [`PpuRevision::Rp2c02H`], byte-identical).
3210    #[must_use]
3211    pub const fn ppu_revision(&self) -> PpuRevision {
3212        self.bus.ppu_revision()
3213    }
3214
3215    /// v2.9.8 — select the console whose reset wiring is modelled (see
3216    /// [`ConsoleModel`]).
3217    ///
3218    /// The selection is stored, so every later [`Nes::power_cycle`] and
3219    /// [`Nes::reset`] follows it. It also takes effect at once in one respect:
3220    /// selecting [`ConsoleModel::Famicom`] ends any PPU warm-up still in
3221    /// progress, because a Famicom's PPU is never held in reset while its CPU
3222    /// runs. A host that applies its configuration straight after building the
3223    /// machine (as the frontend does on every ROM load and power-cycle) thereby
3224    /// gets the Famicom power-on. Selecting [`ConsoleModel::Nes`] never re-arms
3225    /// a window that has already closed; it applies from the next reset.
3226    ///
3227    /// [`ConsoleModel::Nes`] is the default and is byte-identical to every
3228    /// earlier release. Deterministic; config, not save-state.
3229    pub const fn set_console_model(&mut self, model: ConsoleModel) {
3230        self.bus.set_console_model(model);
3231    }
3232
3233    /// v2.9.8 — the currently-selected console reset wiring (default
3234    /// [`ConsoleModel::Nes`], byte-identical).
3235    #[must_use]
3236    pub const fn console_model(&self) -> ConsoleModel {
3237        self.bus.console_model()
3238    }
3239
3240    /// v2.1.7 P5 — apply a power-up palette-RAM pattern (see [`PaletteInit`]).
3241    ///
3242    /// The 2C02's palette RAM is not cleared at power-on; this selects the
3243    /// power-up contents. [`PaletteInit::default`] ([`PaletteInit::Zeroed`])
3244    /// keeps the established all-zero power-up palette, so at the default this is
3245    /// **byte-identical**. [`PaletteInit::Blargg`] loads the canonical blargg
3246    /// power-up dump for software that samples uninitialized palette RAM. Writes
3247    /// only palette RAM (already part of the snapshot), so no snapshot change is
3248    /// needed; the selection is stored so a power-cycle re-applies it. Best
3249    /// called at power-on (palette RAM is preserved across a warm reset, like
3250    /// real hardware).
3251    pub const fn set_power_up_palette(&mut self, init: PaletteInit) {
3252        self.bus.set_power_up_palette(init);
3253    }
3254
3255    /// v2.1.7 P5 — the currently-selected power-up palette pattern (default
3256    /// [`PaletteInit::Zeroed`], byte-identical).
3257    #[must_use]
3258    pub const fn power_up_palette(&self) -> PaletteInit {
3259        self.bus.power_up_palette()
3260    }
3261
3262    /// v2.1.7 P5 — select the power-on work-RAM fill (see [`PowerOnRam`]).
3263    ///
3264    /// Applies the fill to the current 2 KiB work RAM (and open-bus latch)
3265    /// immediately and stores it so a power-cycle re-applies the same fill
3266    /// (`power_cycle == fresh boot`). [`PowerOnRam::default`]
3267    /// ([`PowerOnRam::Zeroed`]) is the established all-zero power-up state
3268    /// (**byte-identical**); the other variants are opt-in and deterministic,
3269    /// surfacing software that reads uninitialized RAM (*Final Fantasy* RNG,
3270    /// *River City Ransom*, *Cybernoid*). RAM is not consulted during reset, so
3271    /// applying it here is safe.
3272    pub fn set_power_on_ram(&mut self, ram: PowerOnRam) {
3273        self.bus.set_power_on_ram(ram);
3274    }
3275
3276    /// v2.1.7 P5 — the currently-selected power-on work-RAM fill (default
3277    /// [`PowerOnRam::Zeroed`], byte-identical).
3278    #[must_use]
3279    pub const fn power_on_ram(&self) -> PowerOnRam {
3280        self.bus.power_on_ram()
3281    }
3282
3283    /// v2.1.7 "Hardware Revisions & DMA Frontier" — select the emulated Ricoh
3284    /// 2A03 die revision, which gates the DMA unit's "unexpected DMA" extra
3285    /// halt-read on the DMC-halt-overlaps-OAM-halt cycle.
3286    ///
3287    /// **[`Cpu2A03Revision::Rp2A03G`] is the default** and is byte-identical to
3288    /// the core as it shipped before v2.1.7 (`AccuracyCoin` 141/141, nestest
3289    /// 0-diff, every committed DMA oracle ROM `Passed`).
3290    /// [`Cpu2A03Revision::Rp2A03H`] is a purely additive, opt-in accuracy knob
3291    /// that omits the extra read; its direction is an **unverified hypothesis**
3292    /// (no public reference emulator or test ROM models the 2A03 die-revision
3293    /// DMA difference — see the type docs and ADR 0033). It is deterministic and
3294    /// a config knob re-applied on load, NOT part of the save-state.
3295    pub const fn set_cpu_2a03_revision(&mut self, revision: Cpu2A03Revision) {
3296        self.bus.set_cpu_2a03_revision(revision);
3297    }
3298
3299    /// v2.1.7 — the configured 2A03 die revision (default
3300    /// [`Cpu2A03Revision::Rp2A03G`], byte-identical to the pre-v2.1.7 core).
3301    #[must_use]
3302    pub const fn cpu_2a03_revision(&self) -> Cpu2A03Revision {
3303        self.bus.cpu_2a03_revision()
3304    }
3305
3306    /// Mapper debug info (bank registers, IRQ counters, mirroring, ...).
3307    #[must_use]
3308    pub fn mapper_info(&self) -> MapperDebugView {
3309        self.bus.mapper_debug_info()
3310    }
3311
3312    /// v1.4.0 Workstream C — the loaded mapper's on-cart expansion-audio chip
3313    /// name (e.g. `"VRC6"`, `"VRC7 (OPLL)"`, `"MMC5"`, `"Namco 163"`,
3314    /// `"Sunsoft 5B"`, `"FDS"`), or `None` when the board has no expansion audio
3315    /// (or the `mapper-audio` feature is compiled out). Used by the frontend to
3316    /// show the expansion-channel volume slider only when present, with a label.
3317    ///
3318    /// Discovery is dynamic: it consults the cached [`rustynes_mappers::MapperCaps`]
3319    /// `audio` flag (true only when the mapper overrides `mix_audio` with the
3320    /// feature on) and the mapper id to name the chip family.
3321    #[must_use]
3322    pub const fn expansion_audio_chip(&self) -> Option<&'static str> {
3323        if !self.bus.mapper_caps().audio {
3324            return None;
3325        }
3326        // The cartridge's id, not the debug view's (v2.9.8, see `mapper_id`).
3327        // Every board that overrides `mix_audio` also names its id in
3328        // `debug_info` today, so no label changes; the debug view simply is
3329        // not the place the mapper id is defined.
3330        Some(match self.mapper_id() {
3331            5 => "MMC5",
3332            19 | 210 => "Namco 163",
3333            20 => "FDS",
3334            24 | 26 => "VRC6",
3335            69 => "Sunsoft 5B",
3336            85 => "VRC7 (OPLL)",
3337            // The board overrides `mix_audio` but isn't one of the named
3338            // families above — surface a generic label so the slider still
3339            // appears (e.g. a future expansion-audio mapper).
3340            _ => "Expansion audio",
3341        })
3342    }
3343
3344    /// Side-effect-free CPU bus peek (for the hex viewer).
3345    pub fn cpu_bus_peek(&mut self, addr: u16) -> u8 {
3346        self.bus.debug_peek_cpu(addr)
3347    }
3348
3349    /// Side-effect-free PPU bus peek (for the hex viewer + visualizers).
3350    pub fn ppu_bus_peek(&mut self, addr: u16) -> u8 {
3351        self.bus.debug_peek_ppu(addr)
3352    }
3353
3354    /// Render the 256 tiles of a CHR pattern table as RGBA8 (128x128).
3355    ///
3356    /// `table` selects which of the two pattern tables: 0 -> `$0000`,
3357    /// 1 -> `$1000`. Uses BG palette 0 ($3F00-$3F03) for grayscale-ish
3358    /// rendering. ~80 KiB cloned; only call when the PPU pattern viewer
3359    /// is open.
3360    pub fn pattern_table_rgba(&mut self, table: u8) -> Vec<u8> {
3361        const TILE_W: usize = 8;
3362        const SHEET_W: usize = 128;
3363        const SHEET_H: usize = 128;
3364        let base: u16 = if table & 1 == 0 { 0 } else { 0x1000 };
3365        let mut out = vec![0u8; SHEET_W * SHEET_H * 4];
3366        for tile_y in 0..16u16 {
3367            for tile_x in 0..16u16 {
3368                let tile_index = tile_y * 16 + tile_x;
3369                for row in 0..8u16 {
3370                    let lo = self.ppu_bus_peek(base + tile_index * 16 + row);
3371                    let hi = self.ppu_bus_peek(base + tile_index * 16 + row + 8);
3372                    for col in 0..8u16 {
3373                        let bit = 7 - col;
3374                        let p = ((hi >> bit) & 1) << 1 | ((lo >> bit) & 1);
3375                        let palette_byte = self.ppu_bus_peek(0x3F00 + u16::from(p));
3376                        let rgba = rustynes_ppu::nes_color_to_rgba(palette_byte & 0x3F);
3377                        let px = usize::from(tile_x) * TILE_W + usize::from(col);
3378                        let py = usize::from(tile_y) * TILE_W + usize::from(row);
3379                        let off = (py * SHEET_W + px) * 4;
3380                        out[off..off + 4].copy_from_slice(&rgba);
3381                    }
3382                }
3383            }
3384        }
3385        out
3386    }
3387
3388    /// Render a nametable as RGBA8 (256x240).
3389    ///
3390    /// `nt` selects 0..=3 logical nametable. Uses the current
3391    /// BG pattern table base, attribute palette, and CHR data.
3392    pub fn nametable_rgba(&mut self, nt: u8) -> Vec<u8> {
3393        const FB_W: usize = 256;
3394        const FB_H: usize = 240;
3395        let nt = nt & 0x03;
3396        let nt_base = 0x2000u16 + u16::from(nt) * 0x400;
3397        let attr_base = nt_base + 0x3C0;
3398        let bg_base = self.bus.ppu().bg_pattern_base();
3399        let mut out = vec![0u8; FB_W * FB_H * 4];
3400        for ty in 0..30u16 {
3401            for tx in 0..32u16 {
3402                let nt_addr = nt_base + ty * 32 + tx;
3403                let tile_idx = self.ppu_bus_peek(nt_addr);
3404                let attr_addr = attr_base + (ty / 4) * 8 + (tx / 4);
3405                let attr_byte = self.ppu_bus_peek(attr_addr);
3406                let shift = ((ty & 2) << 1) | (tx & 2);
3407                let palette = u16::from((attr_byte >> shift) & 0x03);
3408                for row in 0..8u16 {
3409                    let lo = self.ppu_bus_peek(bg_base + u16::from(tile_idx) * 16 + row);
3410                    let hi = self.ppu_bus_peek(bg_base + u16::from(tile_idx) * 16 + row + 8);
3411                    for col in 0..8u16 {
3412                        let bit = 7 - col;
3413                        let p = ((hi >> bit) & 1) << 1 | ((lo >> bit) & 1);
3414                        let final_idx = if p == 0 {
3415                            self.ppu_bus_peek(0x3F00)
3416                        } else {
3417                            self.ppu_bus_peek(0x3F00 + palette * 4 + u16::from(p))
3418                        };
3419                        let rgba = rustynes_ppu::nes_color_to_rgba(final_idx & 0x3F);
3420                        let px = usize::from(tx * 8 + col);
3421                        let py = usize::from(ty * 8 + row);
3422                        let off = (py * FB_W + px) * 4;
3423                        out[off..off + 4].copy_from_slice(&rgba);
3424                    }
3425                }
3426            }
3427        }
3428        out
3429    }
3430}
3431
3432/// The persistent identity of a cartridge image: SHA-256 of everything after
3433/// the 16-byte iNES / NES 2.0 header, or of the whole image when the `NES\x1A`
3434/// magic is absent. See [`Nes::rom_sha256`].
3435fn rom_identity_sha256(bytes: &[u8]) -> [u8; 32] {
3436    match bytes {
3437        [b'N', b'E', b'S', 0x1A, ..] if bytes.len() >= 16 => sha256_of(&bytes[16..]),
3438        _ => sha256_of(bytes),
3439    }
3440}
3441
3442fn sha256_of(bytes: &[u8]) -> [u8; 32] {
3443    let mut h = Sha256::new();
3444    h.update(bytes);
3445    let out = h.finalize();
3446    let mut a = [0u8; 32];
3447    a.copy_from_slice(&out);
3448    a
3449}
3450
3451#[cfg(test)]
3452mod tests {
3453    use super::*;
3454
3455    /// 16-byte iNES header for a synthetic NROM ROM with `prg_kib`/`chr_kib`
3456    /// content, vertical mirroring.
3457    fn synth_nrom(prg_kib: usize, chr_kib: usize) -> Vec<u8> {
3458        let mut bytes = Vec::with_capacity(16 + prg_kib * 1024 + chr_kib * 1024);
3459        bytes.extend_from_slice(b"NES\x1A");
3460        bytes.push(u8::try_from(prg_kib / 16).unwrap());
3461        bytes.push(u8::try_from(chr_kib / 8).unwrap());
3462        bytes.push(0); // flags6
3463        bytes.push(0); // flags7
3464        bytes.extend_from_slice(&[0u8; 8]);
3465
3466        // PRG payload: a tiny program at $C000 that loops forever (JMP $C000).
3467        // Since the reset vector reads $FFFC/D, we set those bytes too.
3468        let mut prg = vec![0u8; prg_kib * 1024];
3469        if prg_kib >= 16 {
3470            // 16 KiB PRG: $C000-$FFFF maps to bytes 0..$4000 of PRG.
3471            // JMP $C000 -> $4C $00 $C0
3472            prg[0] = 0x4C;
3473            prg[1] = 0x00;
3474            prg[2] = 0xC0;
3475            // Reset vector at $FFFC/D = end-of-PRG offsets.
3476            let len = prg.len();
3477            prg[len - 4] = 0x00;
3478            prg[len - 3] = 0xC0;
3479            // NMI vector at $FFFA/B: same.
3480            prg[len - 6] = 0x00;
3481            prg[len - 5] = 0xC0;
3482            // IRQ vector at $FFFE/F: same.
3483            prg[len - 2] = 0x00;
3484            prg[len - 1] = 0xC0;
3485        }
3486        bytes.extend_from_slice(&prg);
3487        bytes.extend_from_slice(&vec![0u8; chr_kib * 1024]);
3488        bytes
3489    }
3490
3491    /// Synthetic NES 2.0 NROM with console type Vs. System and a byte-13 Vs.
3492    /// PPU type (low nibble).
3493    fn synth_vs_nrom(vs_ppu_low_nibble: u8) -> Vec<u8> {
3494        let mut rom = synth_nrom(16, 8);
3495        // Upgrade the header to NES 2.0 + console type Vs. System.
3496        rom[7] = 0x09; // bits 2-3 = 10 (NES 2.0), bits 0-1 = 01 (Vs. System)
3497        rom[13] = vs_ppu_low_nibble & 0x0F;
3498        rom
3499    }
3500
3501    #[test]
3502    fn a_restored_cpu_ppu_clock_skew_is_rejected() {
3503        // v2.7.0: the fuzz target's first timeout. A `ppu_clock` restored far
3504        // from the CPU's `master_clock` made the next catch-up tick billions of
3505        // dots (behind) or never tick at all (ahead) -- a hang either way.
3506        let rom = synth_nrom(16, 8);
3507        let mut nes = Nes::from_rom(&rom).unwrap();
3508        nes.run_frame();
3509        let master = nes.cpu.master_clock();
3510        let live = nes.bus().ppu_clock_for_test();
3511        let live_skew = master.abs_diff(live);
3512        assert!(
3513            live_skew <= SystemBus::RESTORED_CLOCK_SKEW_MAX,
3514            "the running machine's own skew ({live_skew}) must be inside the bound"
3515        );
3516        assert!(
3517            Nes::from_rom(&rom)
3518                .unwrap()
3519                .restore_quiet(&nes.snapshot())
3520                .is_ok()
3521        );
3522
3523        // Measured: a frame of this ROM leaves the pair a handful of master
3524        // clocks apart (4 at the time of writing), and `master_clock` itself
3525        // small, so the "behind" direction is exercised by moving the PPU
3526        // clock only as far as zero allows and the "ahead" direction carries
3527        // the bound.
3528        let max = SystemBus::RESTORED_CLOCK_SKEW_MAX;
3529        for (label, ppu_clock, ok) in [
3530            ("behind, as far as zero", master.saturating_sub(max), true),
3531            ("at the bound, ahead", master + max, true),
3532            ("past the bound, ahead", master + max + 1, false),
3533            ("far ahead", master + (1 << 40), false),
3534            ("u64::MAX", u64::MAX, false),
3535        ] {
3536            nes.bus_mut().set_ppu_clock_for_test(ppu_clock);
3537            let blob = nes.snapshot();
3538            let got = Nes::from_rom(&rom).unwrap().restore_quiet(&blob);
3539            assert_eq!(got.is_ok(), ok, "{label}: {got:?}");
3540        }
3541    }
3542
3543    /// v2.9.0 re-audit NC-04: the clock check bounds the ABSOLUTE clocks, not
3544    /// only their skew.
3545    ///
3546    /// F-05's check accepted any pair within `RESTORED_CLOCK_SKEW_MAX` of each
3547    /// other, so a crafted state with BOTH clocks ~20,000 master clocks below
3548    /// 2^64 loaded. The CPU advances with `wrapping_add`, so its clock wrapped
3549    /// to a small value within a frame while the PPU's stayed near 2^64, and
3550    /// the catch-up loop (`while ppu_clock + div <= target`) never ran again:
3551    /// the frame counter froze, NMI never fired — the exact state F-05 set out
3552    /// to reject. Both clocks are shifted by the same amount here, so the skew
3553    /// is the running machine's own and only the new ceiling can reject it.
3554    #[test]
3555    fn restored_clocks_near_the_top_of_u64_are_rejected() {
3556        let rom = synth_nrom(16, 8);
3557        let mut nes = Nes::from_rom(&rom).unwrap();
3558        nes.run_frame();
3559        nes.run_frame();
3560        let master = nes.cpu.master_clock();
3561        let ppu = nes.bus().ppu_clock_for_test();
3562
3563        // Shift both clocks by `shift` and return the snapshot. The CPU clock
3564        // has no setter, so its bytes are patched inside the CPU section's
3565        // body, where they must occur exactly once.
3566        let shifted = |nes: &mut Nes, shift: u64| {
3567            nes.bus_mut().set_ppu_clock_for_test(ppu + shift);
3568            let mut blob = nes.snapshot();
3569            let (_h, body_off) = save_state::parse_header(&blob).unwrap();
3570            let cpu_body_off = {
3571                let s = save_state::SectionIter::new(&blob[body_off..])
3572                    .map(Result::unwrap)
3573                    .find(|s| s.tag == save_state::tag::CPU)
3574                    .unwrap();
3575                s.body.as_ptr() as usize - blob.as_ptr() as usize
3576            };
3577            let needle = master.to_le_bytes();
3578            let hits: Vec<usize> = (cpu_body_off..blob.len() - 8)
3579                .filter(|&o| blob[o..o + 8] == needle)
3580                .collect();
3581            assert_eq!(hits.len(), 1, "master_clock must occur once: {hits:?}");
3582            blob[hits[0]..hits[0] + 8].copy_from_slice(&(master + shift).to_le_bytes());
3583            nes.bus_mut().set_ppu_clock_for_test(ppu);
3584            blob
3585        };
3586
3587        let ceiling = SystemBus::RESTORED_CLOCK_MAX;
3588        for (label, shift, ok) in [
3589            ("unshifted", 0, true),
3590            (
3591                "a century of emulation",
3592                100 * 365 * 86_400 * 21_477_272,
3593                true,
3594            ),
3595            ("at the ceiling", ceiling - master.max(ppu), true),
3596            ("past the ceiling", ceiling - master.max(ppu) + 1, false),
3597            (
3598                "20,000 master clocks below 2^64",
3599                u64::MAX - 20_000 - master,
3600                false,
3601            ),
3602        ] {
3603            let blob = shifted(&mut nes, shift);
3604            let mut fresh = Nes::from_rom(&rom).unwrap();
3605            let got = fresh.restore_quiet(&blob);
3606            assert_eq!(got.is_ok(), ok, "{label}: {got:?}");
3607            if ok {
3608                // An accepted state must actually run: the frame counter moves.
3609                let f = fresh.frame();
3610                fresh.run_frame();
3611                fresh.run_frame();
3612                assert!(fresh.frame() > f, "{label}: the PPU stopped");
3613            }
3614        }
3615    }
3616
3617    #[test]
3618    fn nes_cart_4016_read_is_byte_identical_with_and_without_vs_inputs() {
3619        // On a normal NES cart the Vs. DIP/coin/service overlay is a no-op, so
3620        // a $4016/$4017 read is byte-for-byte identical regardless of the Vs.
3621        // input state. Compare two freshly-built buses in lockstep.
3622        let rom = synth_nrom(16, 8);
3623        let mut a = Nes::from_rom(&rom).unwrap();
3624        let mut b = Nes::from_rom(&rom).unwrap();
3625        assert!(!a.is_vs_system());
3626        // Crank the Vs. inputs on `b` only.
3627        b.set_vs_dip(0xFF);
3628        b.insert_coin(0);
3629        b.insert_coin(1);
3630        b.set_vs_service(true);
3631        for addr in [0x4016u16, 0x4017, 0x4016, 0x4017] {
3632            assert_eq!(
3633                a.bus_mut().raw_cpu_read(addr),
3634                b.bus_mut().raw_cpu_read(addr),
3635                "Vs. inputs leaked into a normal-cart read of {addr:#06X}"
3636            );
3637        }
3638    }
3639
3640    /// v1.6.0 Workstream A3 — the `TAStudio` lag-log flag: a frame in which the
3641    /// program never reads `$4016`/`$4017` is a lag frame; a controller read
3642    /// marks the frame polled; and the flag resets at the top of each frame.
3643    #[cfg(feature = "debug-hooks")]
3644    #[test]
3645    fn lag_flag_tracks_controller_reads_per_frame() {
3646        // synth_nrom is a pure `JMP $C000` loop — it never polls input.
3647        let rom = synth_nrom(16, 8);
3648        let mut nes = Nes::from_rom(&rom).unwrap();
3649
3650        // A frame of pure JMP never reads a controller port => lag frame.
3651        nes.run_frame();
3652        assert!(
3653            !nes.was_input_polled_this_frame(),
3654            "a frame with no $4016/$4017 read must be a lag frame"
3655        );
3656
3657        // A controller-port read marks the (current) frame as polled.
3658        let _ = nes.bus_mut().raw_cpu_read(0x4016);
3659        assert!(
3660            nes.was_input_polled_this_frame(),
3661            "a $4016 read must mark the frame polled"
3662        );
3663
3664        // $4017 also counts, and the next frame's clear resets the flag.
3665        nes.run_frame();
3666        assert!(
3667            !nes.was_input_polled_this_frame(),
3668            "the flag must reset at the top of each frame"
3669        );
3670        let _ = nes.bus_mut().raw_cpu_read(0x4017);
3671        assert!(
3672            nes.was_input_polled_this_frame(),
3673            "a $4017 read must also mark the frame polled"
3674        );
3675    }
3676
3677    #[test]
3678    fn vs_dip_switches_read_through_4016_and_4017() {
3679        // 2C03 Vs. cart (low nibble 0).
3680        let rom = synth_vs_nrom(0x0);
3681        let mut nes = Nes::from_rom(&rom).unwrap();
3682        assert!(nes.is_vs_system());
3683        // DIP = 0b1010_1010: sw2,4,6,8 on; sw1,3,5,7 off.
3684        nes.set_vs_dip(0b1010_1010);
3685        let v16 = nes.bus_mut().raw_cpu_read(0x4016);
3686        // $4016: DIP sw1 -> bit3 (off), sw2 -> bit4 (on).
3687        assert_eq!(v16 & 0x08, 0x00, "DIP sw1 off");
3688        assert_eq!(v16 & 0x10, 0x10, "DIP sw2 on");
3689        let v17 = nes.bus_mut().raw_cpu_read(0x4017);
3690        // $4017: DIP sw3..8 -> bits 2..7. DIP bits 2..7 = 0b101010.
3691        assert_eq!(v17 & 0xFC, 0b1010_1000 & 0xFC);
3692    }
3693
3694    #[test]
3695    fn vs_coin_and_service_read_through_4016() {
3696        let rom = synth_vs_nrom(0x0);
3697        let mut nes = Nes::from_rom(&rom).unwrap();
3698        nes.set_vs_dip(0);
3699        // Coin acceptor #1 -> $4016 bit 5.
3700        nes.insert_coin(0);
3701        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x20, 0x20);
3702        // Acceptor #2 -> bit 6.
3703        nes.insert_coin(1);
3704        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x60, 0x60);
3705        nes.clear_coin();
3706        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x60, 0x00);
3707        // Service button -> bit 2.
3708        nes.set_vs_service(true);
3709        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x04, 0x04);
3710        nes.set_vs_service(false);
3711        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x04, 0x00);
3712    }
3713
3714    #[test]
3715    fn game_genie_substitutes_on_cpu_read_path() {
3716        // 16 KiB NROM; plant the Zelda code's compare byte (0x22) at the PRG
3717        // address it targets ($9F41 -> $8000-$BFFF window -> PRG offset $1F41).
3718        let mut rom = synth_nrom(16, 8);
3719        rom[16 + 0x1F41] = 0x22;
3720        let mut nes = Nes::from_rom(&rom).expect("synthetic NROM parses");
3721
3722        // No codes active: reads are the original byte (determinism contract).
3723        assert_eq!(nes.bus_mut().debug_peek_cpu(0x9F41), 0x22);
3724        assert_eq!(nes.bus_mut().peek_cpu(0x9F41), 0x22);
3725        assert_eq!(nes.genie_codes().count(), 0);
3726
3727        // 8-char code substitutes only when the original matches compare (0x22),
3728        // on BOTH the production read path and the debugger peek path.
3729        nes.add_genie_code("YYKPOYZZ").expect("valid 8-char code");
3730        assert_eq!(
3731            nes.bus_mut().debug_peek_cpu(0x9F41),
3732            0x77,
3733            "debug peek substituted"
3734        );
3735        assert_eq!(
3736            nes.bus_mut().peek_cpu(0x9F41),
3737            0x77,
3738            "production read substituted"
3739        );
3740        assert_eq!(
3741            nes.bus_mut().debug_peek_cpu(0x9F40),
3742            0x00,
3743            "other address untouched"
3744        );
3745
3746        // Removal (case-insensitive) restores the original byte.
3747        nes.remove_genie_code("yykpoyzz");
3748        assert_eq!(nes.bus_mut().debug_peek_cpu(0x9F41), 0x22);
3749
3750        // 6-char code (no compare) always substitutes; $91D9 -> data 0xAD.
3751        nes.add_genie_code("SXIOPO").expect("valid 6-char code");
3752        assert_eq!(nes.bus_mut().debug_peek_cpu(0x91D9), 0xAD);
3753        nes.clear_genie_codes();
3754        assert_eq!(nes.bus_mut().debug_peek_cpu(0x91D9), 0x00);
3755
3756        // A malformed code is rejected without mutating state.
3757        assert!(nes.add_genie_code("BADCODE!").is_err());
3758    }
3759
3760    #[test]
3761    fn poke_ram_writes_system_ram_and_ignores_rom() {
3762        let rom = synth_nrom(16, 8);
3763        let mut nes = Nes::from_rom(&rom).expect("synthetic NROM parses");
3764        nes.poke_ram(0x0042, 0xAB);
3765        assert_eq!(nes.bus_mut().debug_peek_cpu(0x0042), 0xAB);
3766        // Mirrored every $800 within $0000-$1FFF.
3767        assert_eq!(nes.bus_mut().debug_peek_cpu(0x0842), 0xAB);
3768        // A poke outside system RAM is a no-op (no panic; ROM space untouched).
3769        nes.poke_ram(0x8000, 0xFF);
3770        assert_ne!(nes.bus_mut().debug_peek_cpu(0x8000), 0xFF);
3771    }
3772
3773    #[test]
3774    fn nes_set_buttons_then_strobe_reads_bits_in_order() {
3775        // T-51-005: end-to-end controller plumbing — the bus must shift the
3776        // latched button state out via $4016 in canonical order.
3777        //
3778        // Session-24 / Phase 3 update: `$4016` writes are deferred
3779        // (committed at the start of a later cycle by `Bus::cpu_clock`).
3780        // Direct-API callers that bypass CPU stepping must clock the bus
3781        // between the strobe pulse and the shift-out reads so the buffered
3782        // write commits. Two cycles are sufficient (one for the pending=1
3783        // commit, one as a margin in case the test's first write landed on
3784        // the pending=2 path). Until v2.9.8 these tests drove the removed
3785        // pre-v2.0.0 `tick_one_cpu_cycle`, which committed the strobe the
3786        // same way.
3787        use rustynes_cpu::Bus as _;
3788        let rom = synth_nrom(16, 8);
3789        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3790        nes.set_buttons(0, Buttons::A | Buttons::SELECT | Buttons::DOWN);
3791
3792        // Pulse the strobe latch (write 1 then 0 to $4016), driving the
3793        // bus enough cycles between writes for the deferred-write
3794        // commit to land.
3795        nes.bus_mut().cpu_write(0x4016, 1);
3796        nes.bus_mut().cpu_clock();
3797        nes.bus_mut().cpu_clock();
3798        nes.bus_mut().cpu_write(0x4016, 0);
3799        nes.bus_mut().cpu_clock();
3800        nes.bus_mut().cpu_clock();
3801
3802        // 8 reads of $4016 should yield A, B, Select, Start, Up, Down, Left, Right.
3803        let expected = [1u8, 0, 1, 0, 0, 1, 0, 0];
3804        for &want in &expected {
3805            let v = nes.bus_mut().cpu_read(0x4016) & 1;
3806            assert_eq!(v, want);
3807        }
3808    }
3809
3810    #[test]
3811    fn nes_set_buttons_port1_reads_via_4017_in_order() {
3812        // T-71-004 (Phase 7): player 2 plumbing. The strobe latch is shared
3813        // (writing `$4016` strobes BOTH pads); player 2 shifts out on `$4017`.
3814        // Mirrors `nes_set_buttons_then_strobe_reads_bits_in_order` for port 1.
3815        use rustynes_cpu::Bus as _;
3816        let rom = synth_nrom(16, 8);
3817        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3818        nes.set_buttons(1, Buttons::B | Buttons::START | Buttons::RIGHT);
3819
3820        nes.bus_mut().cpu_write(0x4016, 1);
3821        nes.bus_mut().cpu_clock();
3822        nes.bus_mut().cpu_clock();
3823        nes.bus_mut().cpu_write(0x4016, 0);
3824        nes.bus_mut().cpu_clock();
3825        nes.bus_mut().cpu_clock();
3826
3827        // A, B, Select, Start, Up, Down, Left, Right.
3828        let expected = [0u8, 1, 0, 1, 0, 0, 0, 1];
3829        for (i, &want) in expected.iter().enumerate() {
3830            let v = nes.bus_mut().cpu_read(0x4017) & 1;
3831            assert_eq!(v, want, "$4017 read #{i}");
3832        }
3833    }
3834
3835    #[test]
3836    fn nes_restrobe_relatches_current_buttons() {
3837        // T-71-004 (Phase 7): a fresh strobe re-samples the live button state
3838        // through the full bus (the per-`Controller` unit test in
3839        // `controller.rs` covers this at the chip level; this confirms it end
3840        // to end via `Nes::set_buttons` + `$4016`).
3841        use rustynes_cpu::Bus as _;
3842        let rom = synth_nrom(16, 8);
3843        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3844
3845        let strobe = |nes: &mut Nes| {
3846            nes.bus_mut().cpu_write(0x4016, 1);
3847            nes.bus_mut().cpu_clock();
3848            nes.bus_mut().cpu_clock();
3849            nes.bus_mut().cpu_write(0x4016, 0);
3850            nes.bus_mut().cpu_clock();
3851            nes.bus_mut().cpu_clock();
3852        };
3853
3854        nes.set_buttons(0, Buttons::A);
3855        strobe(&mut nes);
3856        assert_eq!(nes.bus_mut().cpu_read(0x4016) & 1, 1, "A latched pressed");
3857
3858        // Change state, then re-strobe: the new state must be visible.
3859        nes.set_buttons(0, Buttons::empty());
3860        strobe(&mut nes);
3861        assert_eq!(nes.bus_mut().cpu_read(0x4016) & 1, 0, "A latched released");
3862    }
3863
3864    #[test]
3865    fn reading_4015_does_not_refresh_external_open_bus() {
3866        // T-72-006 (Phase 7): `$4015` reads return the APU status but do NOT
3867        // drive the external data bus (the APU status port is internal to the
3868        // 2A03 package). So a `$4015` read must leave the open-bus latch
3869        // unchanged — a subsequent open-bus-region read returns the prior
3870        // floating value, not the APU status. Per nesdev "Open bus behavior"
3871        // + AccuracyCoin `CPU Behavior :: Open Bus` Test 7.
3872        use rustynes_cpu::Bus as _;
3873        let rom = synth_nrom(16, 8);
3874        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3875
3876        // Drive a known value onto the external bus via a normal RAM read.
3877        nes.bus_mut().cpu_write(0x0010, 0xAB);
3878        assert_eq!(nes.bus_mut().cpu_read(0x0010), 0xAB);
3879        // $4018-$401F is open-bus region: returns (and re-latches) the value.
3880        assert_eq!(
3881            nes.bus_mut().cpu_read(0x4018),
3882            0xAB,
3883            "open-bus latch holds 0xAB"
3884        );
3885
3886        // Read $4015 — must NOT refresh the external latch.
3887        let _ = nes.bus_mut().cpu_read(0x4015);
3888
3889        // The latch is still 0xAB, not whatever APU status $4015 returned.
3890        assert_eq!(
3891            nes.bus_mut().cpu_read(0x4018),
3892            0xAB,
3893            "$4015 read must not drive the external data bus"
3894        );
3895    }
3896
3897    #[test]
3898    fn nes_from_rom_constructs_and_resets() {
3899        let rom = synth_nrom(16, 8);
3900        let nes = Nes::from_rom(&rom).expect("parse + boot");
3901        assert_eq!(nes.cpu().pc, 0xC000);
3902    }
3903
3904    #[test]
3905    fn power_on_randomization_is_opt_in_seeded_and_deterministic() {
3906        // T-72-005 (Phase 7): the default path leaves work RAM zeroed; the
3907        // seeded constructor randomizes it deterministically.
3908        let rom = synth_nrom(16, 8);
3909
3910        // Default: RAM is zeroed.
3911        let mut default = Nes::from_rom(&rom).expect("parse + boot");
3912        for addr in (0x0000u16..0x0800).step_by(0x40) {
3913            assert_eq!(default.cpu_bus_peek(addr), 0, "default RAM must be zero");
3914        }
3915
3916        // Seeded: RAM is not all-zero.
3917        let mut a = Nes::from_rom_with_power_on_seed(&rom, 1).expect("parse + boot");
3918        let dump_a: Vec<u8> = (0x0000u16..0x0100).map(|x| a.cpu_bus_peek(x)).collect();
3919        assert!(
3920            dump_a.iter().any(|&b| b != 0),
3921            "seeded RAM must not be all zero"
3922        );
3923
3924        // Same seed -> identical RAM.
3925        let mut a2 = Nes::from_rom_with_power_on_seed(&rom, 1).expect("parse + boot");
3926        let dump_a2: Vec<u8> = (0x0000u16..0x0100).map(|x| a2.cpu_bus_peek(x)).collect();
3927        assert_eq!(
3928            dump_a, dump_a2,
3929            "same seed must yield identical power-on RAM"
3930        );
3931
3932        // Different seed -> different RAM.
3933        let mut b = Nes::from_rom_with_power_on_seed(&rom, 0xDEAD_BEEF).expect("parse + boot");
3934        let dump_b: Vec<u8> = (0x0000u16..0x0100).map(|x| b.cpu_bus_peek(x)).collect();
3935        assert_ne!(dump_a, dump_b, "different seeds should differ");
3936    }
3937
3938    #[test]
3939    fn power_on_config_defaults_byte_identical_and_variants_deterministic() {
3940        // v2.1.7 P5 — the PowerOnConfig surface. Default (Zeroed) must match the
3941        // plain constructor; Filled + Seeded must be deterministic and distinct.
3942        let rom = synth_nrom(16, 8);
3943
3944        // Default config == from_rom (byte-identical work RAM).
3945        let mut zeroed =
3946            Nes::from_rom_with_power_on_config(&rom, PowerOnConfig::default()).expect("boot");
3947        assert_eq!(zeroed.power_on_ram(), PowerOnRam::Zeroed);
3948        for addr in (0x0000u16..0x0800).step_by(0x40) {
3949            assert_eq!(zeroed.cpu_bus_peek(addr), 0, "Zeroed config: RAM zero");
3950        }
3951
3952        // Filled(0xFF): every work-RAM byte is 0xFF, deterministically.
3953        let mut filled = Nes::from_rom_with_power_on_config(
3954            &rom,
3955            PowerOnConfig {
3956                ram: PowerOnRam::Filled(0xFF),
3957            },
3958        )
3959        .expect("boot");
3960        assert_eq!(filled.power_on_ram(), PowerOnRam::Filled(0xFF));
3961        for addr in (0x0000u16..0x0800).step_by(0x40) {
3962            assert_eq!(filled.cpu_bus_peek(addr), 0xFF, "Filled(0xFF)");
3963        }
3964
3965        // Seeded is deterministic and differs from Zeroed.
3966        let mut seeded = Nes::from_rom_with_power_on_config(
3967            &rom,
3968            PowerOnConfig {
3969                ram: PowerOnRam::Seeded(42),
3970            },
3971        )
3972        .expect("boot");
3973        let dump: Vec<u8> = (0x0000u16..0x0100)
3974            .map(|x| seeded.cpu_bus_peek(x))
3975            .collect();
3976        assert!(dump.iter().any(|&b| b != 0), "Seeded: not all zero");
3977    }
3978
3979    #[test]
3980    fn ppu_revision_and_palette_default_byte_identical() {
3981        // v2.1.7 P5 — the PPU-revision + power-up-palette knobs default to the
3982        // byte-identical state, and toggling them is observable + power-cycle
3983        // durable.
3984        let rom = synth_nrom(16, 8);
3985        let mut nes = Nes::from_rom(&rom).expect("boot");
3986        assert_eq!(nes.ppu_revision(), PpuRevision::Rp2c02H);
3987        assert_eq!(nes.power_up_palette(), PaletteInit::Zeroed);
3988
3989        // Select the opt-in revision + Blargg palette; both must persist across a
3990        // power-cycle (the bus re-applies them after rebuilding the PPU).
3991        nes.set_ppu_revision(PpuRevision::Rp2c02G);
3992        nes.set_power_up_palette(PaletteInit::Blargg);
3993        nes.power_cycle();
3994        assert_eq!(
3995            nes.ppu_revision(),
3996            PpuRevision::Rp2c02G,
3997            "revision survives power-cycle"
3998        );
3999        assert_eq!(
4000            nes.power_up_palette(),
4001            PaletteInit::Blargg,
4002            "palette survives power-cycle"
4003        );
4004    }
4005
4006    /// v2.9.8 — a ROM that writes PPUCTRL and PPUMASK exactly once, as its
4007    /// first four instructions, then idles. Whether those writes land is the
4008    /// whole observable difference between the two console models at power-on.
4009    ///
4010    /// ```text
4011    /// C000: A9 90     LDA #$90    ; NMI on, BG pattern table $1000
4012    /// C002: 8D 00 20  STA $2000
4013    /// C005: A9 01     LDA #$01    ; greyscale only: rendering stays off
4014    /// C007: 8D 01 20  STA $2001
4015    /// C00A: 4C 0A C0  JMP $C00A
4016    /// C00D: 40        RTI         ; NMI handler
4017    /// ```
4018    fn one_shot_ppu_write_rom() -> Vec<u8> {
4019        // (Two frames are run per check below, not one: the machine powers up
4020        // with the PPU at the end of the pre-render line, so the first
4021        // `run_frame` completes within a few cycles, before these writes.)
4022        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
4023        bytes[0..4].copy_from_slice(b"NES\x1A");
4024        bytes[4] = 1; // 1x16 KiB PRG, CHR-RAM
4025        let prg = &mut bytes[16..];
4026        prg[0..14].copy_from_slice(&[
4027            0xA9, 0x90, 0x8D, 0x00, 0x20, 0xA9, 0x01, 0x8D, 0x01, 0x20, 0x4C, 0x0A, 0xC0, 0x40,
4028        ]);
4029        let len = prg.len();
4030        prg[len - 6] = 0x0D; // NMI -> $C00D (RTI)
4031        prg[len - 5] = 0xC0;
4032        prg[len - 4] = 0x00; // RESET -> $C000
4033        prg[len - 3] = 0xC0;
4034        prg[len - 2] = 0x0A; // IRQ -> $C00A
4035        prg[len - 1] = 0xC0;
4036        bytes
4037    }
4038
4039    fn run_frames(nes: &mut Nes, n: u32) {
4040        for _ in 0..n {
4041            let _ = nes.run_frame();
4042        }
4043    }
4044
4045    /// v2.9.8 — the console model's two documented effects, from `NESdev` "PPU
4046    /// power up state" (§Famicom and the front-/top-loader note):
4047    ///
4048    /// 1. At power-on the NES ignores `$2000`/`$2001` for ~29,658 CPU cycles,
4049    ///    so a write issued in the first few cycles is lost; on the Famicom the
4050    ///    PPU left reset about one frame earlier, so the same write lands.
4051    /// 2. The Reset button resets the PPU on the NES (PPUCTRL cleared, the
4052    ///    warm-up window re-armed) and does not reach it on the Famicom.
4053    #[test]
4054    fn famicom_console_model_ppu_leaves_reset_before_the_cpu() {
4055        let rom = one_shot_ppu_write_rom();
4056
4057        // Default (NES): the model is NES, and the one-shot writes are dropped
4058        // inside the warm-up window — the established behaviour.
4059        let mut nes = Nes::from_rom(&rom).expect("boot");
4060        assert_eq!(nes.console_model(), ConsoleModel::Nes);
4061        run_frames(&mut nes, 2);
4062        assert_eq!(
4063            nes.bus().ppu().debug_registers()[..2],
4064            [0x00, 0x00],
4065            "NES: writes inside the warm-up window are ignored"
4066        );
4067
4068        // Famicom, selected straight after construction the way the frontend
4069        // applies its config: the same writes land.
4070        let mut fc = Nes::from_rom(&rom).expect("boot");
4071        fc.set_console_model(ConsoleModel::Famicom);
4072        assert_eq!(fc.bus().ppu().warmup_cycles_remaining(), 0);
4073        run_frames(&mut fc, 2);
4074        assert_eq!(
4075            fc.bus().ppu().debug_registers()[..2],
4076            [0x90, 0x01],
4077            "Famicom: the PPU is past its warm-up when the CPU starts"
4078        );
4079
4080        // Reset reaches only the CPU on a Famicom: PPUCTRL/PPUMASK survive and
4081        // no window is re-armed.
4082        fc.reset();
4083        assert_eq!(fc.bus().ppu().debug_registers()[..2], [0x90, 0x01]);
4084        assert_eq!(fc.bus().ppu().warmup_cycles_remaining(), 0);
4085
4086        // On the NES, Reset clears PPUCTRL/PPUMASK and re-arms the window.
4087        let mut nes = Nes::from_rom(&rom).expect("boot");
4088        nes.set_console_model(ConsoleModel::Famicom);
4089        run_frames(&mut nes, 2);
4090        assert_eq!(nes.bus().ppu().debug_registers()[..2], [0x90, 0x01]);
4091        nes.set_console_model(ConsoleModel::Nes);
4092        nes.reset();
4093        assert_eq!(nes.bus().ppu().debug_registers()[..2], [0x00, 0x00]);
4094        assert!(nes.bus().ppu().warmup_cycles_remaining() > 29_000);
4095
4096        // The selection survives a power cycle and is applied to the rebuilt
4097        // PPU before the CPU's first instruction.
4098        fc.power_cycle();
4099        assert_eq!(fc.console_model(), ConsoleModel::Famicom);
4100        assert_eq!(fc.bus().ppu().warmup_cycles_remaining(), 0);
4101        run_frames(&mut fc, 2);
4102        assert_eq!(fc.bus().ppu().debug_registers()[..2], [0x90, 0x01]);
4103    }
4104
4105    #[test]
4106    fn nes_run_frame_completes_and_returns_framebuffer() {
4107        let rom = synth_nrom(16, 8);
4108        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4109        let fb = nes.run_frame();
4110        assert_eq!(fb.len(), 256 * 240 * 4);
4111    }
4112
4113    #[test]
4114    fn nes_run_two_frames_distinct_completion_latches() {
4115        let rom = synth_nrom(16, 8);
4116        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4117        nes.run_frame();
4118        let cycles_after_one = nes.cycle();
4119        nes.run_frame();
4120        let cycles_after_two = nes.cycle();
4121        assert!(cycles_after_two > cycles_after_one);
4122    }
4123
4124    #[test]
4125    fn nes_determinism_two_runs_match() {
4126        // T-24-002: same ROM + zero input + 60 frames -> bit-identical
4127        // framebuffer hash via FNV-1a.
4128        fn hash_fb(fb: &[u8]) -> u64 {
4129            let mut h: u64 = 0xCBF2_9CE4_8422_2325;
4130            for &b in fb {
4131                h ^= u64::from(b);
4132                h = h.wrapping_mul(0x0000_0100_0000_01B3);
4133            }
4134            h
4135        }
4136        let rom = synth_nrom(16, 8);
4137        let mut a = Nes::from_rom(&rom).unwrap();
4138        let mut b = Nes::from_rom(&rom).unwrap();
4139        let frames = 4;
4140        let mut hash_a = 0u64;
4141        let mut hash_b = 0u64;
4142        for _ in 0..frames {
4143            hash_a = hash_fb(a.run_frame());
4144            hash_b = hash_fb(b.run_frame());
4145        }
4146        assert_eq!(
4147            hash_a, hash_b,
4148            "two runs must produce identical framebuffer"
4149        );
4150    }
4151
4152    fn fnv_hash(bytes: &[u8]) -> u64 {
4153        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
4154        for &b in bytes {
4155            h ^= u64::from(b);
4156            h = h.wrapping_mul(0x0000_0100_0000_01B3);
4157        }
4158        h
4159    }
4160
4161    #[test]
4162    fn snapshot_round_trip_preserves_framebuffer_and_cycle() {
4163        let rom = synth_nrom(16, 8);
4164        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4165        for _ in 0..4 {
4166            nes.run_frame();
4167        }
4168        let cycle = nes.cycle();
4169        let fb_hash_before = fnv_hash(nes.framebuffer());
4170        let blob = nes.snapshot();
4171
4172        // Drift the emulator forward 4 more frames so it looks different.
4173        for _ in 0..4 {
4174            nes.run_frame();
4175        }
4176        assert_ne!(nes.cycle(), cycle, "drift must move us off the snapshot");
4177
4178        nes.restore(&blob).expect("restore");
4179        assert_eq!(nes.cycle(), cycle);
4180        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash_before);
4181    }
4182
4183    // ---------------------------------------------------------------------
4184    // Cartridge-RAM save-state sweep (core audit v2.9.2 AUD-02 and its
4185    // follow-up).
4186    //
4187    // The `.rns` container has no SRAM section: the `MAP ` section is the
4188    // mapper's own `save_state` blob and nothing else, so cartridge RAM
4189    // survives a save-state load, rewind step, run-ahead frame or netplay
4190    // rollback ONLY if the board writes it into that blob. AUD-02 found the
4191    // Konami VRC boards leaving theirs out; a one-off sweep then found the
4192    // same omission on boards outside that family. These tests are that sweep
4193    // made durable: they walk EVERY mapper id `rustynes_mappers::parse`
4194    // constructs, so a board added later is checked the day it lands rather
4195    // than the day its RAM goes missing in someone's rewind.
4196    // ---------------------------------------------------------------------
4197
4198    use alloc::{boxed::Box, string::String};
4199
4200    /// How a sweep image's header is encoded.
4201    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
4202    enum SweepHeader {
4203        /// iNES 1.0 (mapper ids 0-255 only). The parser defaults an iNES 1.0
4204        /// cart to 8 KiB PRG-RAM and, with no CHR-ROM, 8 KiB CHR-RAM.
4205        Ines1,
4206        /// NES 2.0 with this submapper, declaring 8 KiB battery PRG-NVRAM and,
4207        /// with no CHR-ROM, 8 KiB CHR-RAM. Covers ids 256-4095 and every
4208        /// submapper-selected variant of the lower ids.
4209        Nes2 { submapper: u8 },
4210    }
4211
4212    /// Synthetic cartridge image for mapper `mapper_id`: `prg_kib` KiB PRG,
4213    /// `chr_kib` KiB CHR-ROM (`0` = none, so the board allocates CHR-RAM),
4214    /// battery flag set so boards that gate `sram()` on the battery bit
4215    /// expose their `$6000-$7FFF` RAM. Every 8 KiB PRG bank ends in a vector
4216    /// table pointing at a `JMP $E000` idle loop, so whichever bank a board
4217    /// maps at `$E000` on power-on, the CPU spins harmlessly.
4218    fn synth_board_rom(
4219        mapper_id: u16,
4220        header: SweepHeader,
4221        prg_kib: usize,
4222        chr_kib: usize,
4223    ) -> Vec<u8> {
4224        let mut bytes = Vec::with_capacity(16 + (prg_kib + chr_kib) * 1024);
4225        bytes.extend_from_slice(b"NES\x1A");
4226        bytes.push(u8::try_from(prg_kib / 16).expect("PRG size fits the header"));
4227        bytes.push(u8::try_from(chr_kib / 8).expect("CHR size fits the header"));
4228        let [id_lo, id_hi] = mapper_id.to_le_bytes();
4229        bytes.push(((id_lo & 0x0F) << 4) | 0x02); // flags6: battery
4230        match header {
4231            SweepHeader::Ines1 => {
4232                assert_eq!(id_hi, 0, "iNES 1.0 carries 8-bit mapper ids only");
4233                bytes.push(id_lo & 0xF0); // flags7: mapper high nibble
4234                bytes.extend_from_slice(&[0u8; 8]);
4235            }
4236            SweepHeader::Nes2 { submapper } => {
4237                bytes.push((id_lo & 0xF0) | 0x08); // flags7: NES 2.0 marker
4238                bytes.push((submapper << 4) | (id_hi & 0x0F)); // byte 8
4239                bytes.push(0); // byte 9: ROM size MSBs
4240                bytes.push(0x70); // byte 10: 8 KiB (64 << 7) battery PRG-NVRAM
4241                bytes.push(if chr_kib == 0 { 0x07 } else { 0x00 }); // byte 11: CHR-RAM
4242                bytes.extend_from_slice(&[0u8; 4]); // bytes 12-15: NTSC, no Vs.
4243            }
4244        }
4245        let mut prg = vec![0u8; prg_kib * 1024];
4246        for bank in prg.chunks_mut(8 * 1024) {
4247            bank[0] = 0x4C; // JMP $E000
4248            bank[1] = 0x00;
4249            bank[2] = 0xE0;
4250            let len = bank.len();
4251            for v in [len - 6, len - 4, len - 2] {
4252                bank[v] = 0x00;
4253                bank[v + 1] = 0xE0;
4254            }
4255        }
4256        bytes.extend_from_slice(&prg);
4257        bytes.extend_from_slice(&vec![0u8; chr_kib * 1024]);
4258        bytes
4259    }
4260
4261    /// Result of trying to build one sweep configuration.
4262    enum SweepBoot {
4263        /// `parse` does not know the mapper id at all.
4264        Unsupported,
4265        /// Built with the first image shape the board accepted.
4266        Built(Box<Nes>),
4267        /// The id is known but no image shape was accepted (last error).
4268        Refused(String),
4269    }
4270
4271    /// Build `mapper_id` under `header`, trying the image shapes in turn
4272    /// until the board's constructor accepts one. Most boards take the first
4273    /// (128 KiB PRG, the shape AUD-02's VRC pins used); a board with a fixed
4274    /// PRG size (NROM's 16/32 KiB, a 32 KiB-only discrete board) takes a later
4275    /// one. RAM sizes come from the header, not the ROM size, so the shape
4276    /// does not change what RAM the board allocates.
4277    fn sweep_boot(mapper_id: u16, header: SweepHeader, chr_ram: bool) -> SweepBoot {
4278        const PRG_KIB: [usize; 8] = [128, 32, 512, 256, 64, 16, 1024, 2048];
4279        const CHR_ROM_KIB: [usize; 5] = [128, 8, 256, 512, 1024];
4280        let chr_shapes: &[usize] = if chr_ram { &[0] } else { &CHR_ROM_KIB };
4281        let mut last = String::new();
4282        for &prg in &PRG_KIB {
4283            for &chr in chr_shapes {
4284                match Nes::from_rom(&synth_board_rom(mapper_id, header, prg, chr)) {
4285                    Ok(nes) => return SweepBoot::Built(Box::new(nes)),
4286                    Err(RomError::UnsupportedMapper(_)) => return SweepBoot::Unsupported,
4287                    Err(e) => last = alloc::format!("{prg} KiB PRG / {chr} KiB CHR: {e}"),
4288                }
4289            }
4290        }
4291        SweepBoot::Refused(last)
4292    }
4293
4294    /// Byte `i` of the sweep's position-dependent pattern, `seed` keeping
4295    /// the PRG and CHR patterns distinct. Position-dependent so a shifted,
4296    /// truncated or aliased copy cannot pass for the real thing.
4297    fn sweep_pattern(i: usize, seed: u8) -> u8 {
4298        let le = i.to_le_bytes();
4299        le[0] ^ seed ^ le[1].rotate_left(3)
4300    }
4301
4302    /// Where a probe found RAM.
4303    #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
4304    enum RamSite {
4305        /// PRG-RAM exposed through `Mapper::sram` (the whole buffer).
4306        PrgSram,
4307        /// PRG-RAM reachable at `$6000-$7FFF` but NOT exposed through
4308        /// `sram()` -- checked through the CPU window instead.
4309        PrgWindow,
4310        /// Writable CHR memory reachable at PPU `$0000-$1FFF`.
4311        ChrRam,
4312    }
4313
4314    /// Describe the mismatch between two equal-length buffers, or `None`.
4315    fn sweep_diff(want: &[u8], got: &[u8]) -> Option<String> {
4316        let bad = want.iter().zip(got).filter(|(a, b)| a != b).count();
4317        let first = want.iter().zip(got).position(|(a, b)| a != b)?;
4318        Some(alloc::format!(
4319            "{bad} of {} bytes differ, first at +{first:#06x} (want {:#04x}, got {:#04x})",
4320            want.len(),
4321            want[first],
4322            got[first]
4323        ))
4324    }
4325
4326    /// PRG-RAM round trip through the whole-machine snapshot. Returns the
4327    /// site probed (`None` when the board has no PRG-RAM the probe can reach)
4328    /// and the mismatch, if any.
4329    ///
4330    /// A board that exposes `sram()` is checked on the whole buffer, so RAM
4331    /// the CPU window cannot currently see (an unmapped page of a 32 KiB
4332    /// board, say) is covered too. A board that does not is probed at
4333    /// `$6000-$7FFF`: write the pattern, and if at least 2 KiB of it reads
4334    /// back (the smallest cartridge RAM; a mirrored 2 KiB part returns only
4335    /// its last alias), treat it as RAM and round-trip the window.
4336    fn sweep_prg_ram(nes: &mut Nes) -> (Option<RamSite>, Option<String>) {
4337        if !nes.sram().is_empty() {
4338            for (i, b) in nes.sram_mut().iter_mut().enumerate() {
4339                *b = sweep_pattern(i, 0xA5);
4340            }
4341            let want = nes.sram().to_vec();
4342            let blob = nes.snapshot();
4343            // Scribble with the complement, so every byte differs from what
4344            // the snapshot must bring back.
4345            for b in nes.sram_mut() {
4346                *b = !*b;
4347            }
4348            if let Err(e) = nes.restore(&blob) {
4349                return (
4350                    Some(RamSite::PrgSram),
4351                    Some(alloc::format!("restore: {e:?}")),
4352                );
4353            }
4354            return (Some(RamSite::PrgSram), sweep_diff(&want, nes.sram()));
4355        }
4356        let window = 0x6000u16..0x8000;
4357        for a in window.clone() {
4358            nes.bus
4359                .mapper
4360                .cpu_write(a, sweep_pattern(usize::from(a - 0x6000), 0xA5));
4361        }
4362        // Snapshot BEFORE reading back: the read below and the one after the
4363        // restore then both start from the snapshot's state, so a board whose
4364        // reads move state cannot make a correct restore look wrong.
4365        let blob = nes.snapshot();
4366        let want: Vec<u8> = window.clone().map(|a| nes.bus.mapper.cpu_read(a)).collect();
4367        let echoed = want
4368            .iter()
4369            .enumerate()
4370            .filter(|&(i, &b)| b == sweep_pattern(i, 0xA5))
4371            .count();
4372        if echoed < 0x800 {
4373            return (None, None);
4374        }
4375        for (a, w) in window.clone().zip(&want) {
4376            nes.bus.mapper.cpu_write(a, !w);
4377        }
4378        if let Err(e) = nes.restore(&blob) {
4379            return (
4380                Some(RamSite::PrgWindow),
4381                Some(alloc::format!("restore: {e:?}")),
4382            );
4383        }
4384        let got: Vec<u8> = window.map(|a| nes.bus.mapper.cpu_read(a)).collect();
4385        (Some(RamSite::PrgWindow), sweep_diff(&want, &got))
4386    }
4387
4388    /// v3.1.0 — `debug_peek_ppu` is the debugger's and the HD-pack
4389    /// compositor's "side-effect-free" CHR read. On the five boards whose CHR
4390    /// reads change them (`Mapper::chr_reads_are_pure`), it used to flip the
4391    /// board: reading all of CHR on MMC2 or MMC4 (the pattern viewer does)
4392    /// passes tiles `$FD`/`$FE` and switches a CHR latch, so opening a debugger
4393    /// panel, or hashing a tile for an HD pack, changed the game. Every board's
4394    /// whole-machine state must be unchanged by a full sweep of peeks.
4395    #[test]
4396    fn debug_peek_ppu_changes_no_board() {
4397        // The five impure families' ids, and MMC3 (4) as a pure control.
4398        for id in [9u16, 10, 35, 90, 96, 163, 209, 211, 4] {
4399            let rom = synth_board_rom(id, SweepHeader::Ines1, 128, 128);
4400            let Ok(mut nes) = Nes::from_rom(&rom) else {
4401                panic!("mapper {id} builds");
4402            };
4403            nes.run_frame();
4404            let before = nes.snapshot();
4405            for a in 0u16..0x2000 {
4406                let _ = nes.bus.debug_peek_ppu(a);
4407            }
4408            let after = nes.snapshot();
4409            assert!(
4410                before == after,
4411                "mapper {id}: peeking CHR changed the machine ({} differing snapshot bytes)",
4412                before.iter().zip(&after).filter(|(a, b)| a != b).count()
4413            );
4414        }
4415    }
4416
4417    /// CHR-RAM round trip through the whole-machine snapshot, through PPU
4418    /// `$0000-$1FFF` (there is no raw CHR accessor on `Mapper`). Returns
4419    /// `None` for the site when fewer than 1 KiB of the pattern reads back,
4420    /// i.e. the board has no writable CHR (a CHR-ROM board).
4421    ///
4422    /// Only the 8 KiB the power-on banking maps is checked; a board with more
4423    /// CHR-RAM than that is checked on its visible part. Every write lands
4424    /// before the snapshot and every read after it, from the same state on
4425    /// both sides: MMC2 and MMC4 flip their CHR latches on PPU reads of
4426    /// `$0FD8`/`$0FE8`, so a read sequence started from different latch
4427    /// states would compare different banks and fail a correct restore.
4428    fn sweep_chr_ram(nes: &mut Nes) -> (Option<RamSite>, Option<String>) {
4429        for a in 0u16..0x2000 {
4430            nes.bus
4431                .mapper
4432                .ppu_write(a, sweep_pattern(usize::from(a), 0x3C));
4433        }
4434        let blob = nes.snapshot();
4435        let want: Vec<u8> = (0u16..0x2000).map(|a| nes.bus.debug_peek_ppu(a)).collect();
4436        let echoed = want
4437            .iter()
4438            .enumerate()
4439            .filter(|&(i, &b)| b == sweep_pattern(i, 0x3C))
4440            .count();
4441        if echoed < 0x400 {
4442            return (None, None);
4443        }
4444        for (a, w) in (0u16..0x2000).zip(&want) {
4445            nes.bus.mapper.ppu_write(a, !w);
4446        }
4447        if let Err(e) = nes.restore(&blob) {
4448            return (
4449                Some(RamSite::ChrRam),
4450                Some(alloc::format!("restore: {e:?}")),
4451            );
4452        }
4453        let got: Vec<u8> = (0u16..0x2000).map(|a| nes.bus.debug_peek_ppu(a)).collect();
4454        (Some(RamSite::ChrRam), sweep_diff(&want, &got))
4455    }
4456
4457    /// Mapper ids `parse` knows but cannot build under ANY sweep
4458    /// configuration, each with the reason. A board on this list is excluded
4459    /// from the sweep by name, never silently.
4460    const SWEEP_UNBUILDABLE: &[(u16, &str)] = &[];
4461
4462    /// The sweep's `--nocapture` report: what was checked where, which built
4463    /// boards had no RAM the probes could reach, and which configurations a
4464    /// board refused (a CHR-ROM-only board refusing a CHR-RAM image, a
4465    /// fixed-size board refusing the larger PRG shapes). Printed, not
4466    /// asserted, so a reader can see the sweep's reach on every run.
4467    fn sweep_print_report(
4468        supported: &alloc::collections::BTreeSet<u16>,
4469        built: &alloc::collections::BTreeSet<u16>,
4470        checked: &alloc::collections::BTreeMap<u16, alloc::collections::BTreeSet<RamSite>>,
4471        refusals: &alloc::collections::BTreeMap<u16, Vec<String>>,
4472    ) {
4473        let no_ram: Vec<u16> = built
4474            .iter()
4475            .copied()
4476            .filter(|m| !checked.contains_key(m))
4477            .collect();
4478        std::println!(
4479            "cartridge-RAM sweep: {} supported ids, {} built, {} with RAM checked",
4480            supported.len(),
4481            built.len(),
4482            checked.len()
4483        );
4484        for site in [RamSite::PrgSram, RamSite::PrgWindow, RamSite::ChrRam] {
4485            let ids: Vec<u16> = checked
4486                .iter()
4487                .filter(|(_, s)| s.contains(&site))
4488                .map(|(m, _)| *m)
4489                .collect();
4490            std::println!("  {site:?} ({}): {ids:?}", ids.len());
4491        }
4492        std::println!("  no RAM reachable ({}): {no_ram:?}", no_ram.len());
4493        for (m, why) in refusals {
4494            std::println!(
4495                "  mapper {m}: {} configuration(s) refused, e.g. {}",
4496                why.len(),
4497                why[0]
4498            );
4499        }
4500    }
4501
4502    /// Core audit v2.9.2 AUD-02 and its follow-up: every board's PRG-RAM and
4503    /// CHR-RAM must survive a whole-machine `snapshot` / `restore`.
4504    ///
4505    /// Sweeps every mapper id 0-4095 (NES 2.0, all 16 submappers; iNES 1.0
4506    /// too for 0-255), each with CHR-ROM and with CHR-RAM. For every
4507    /// configuration that builds it fills the board's RAM with a pattern,
4508    /// snapshots the `Nes`, scribbles the RAM with the complement, restores
4509    /// and compares. Supersedes the VRC-only pins AUD-02 added
4510    /// (`vrc_boards_snapshot_carries_{prg,chr}_ram`), and asserts those
4511    /// boards are still among the ones checked, so the sweep can never pass
4512    /// by checking less.
4513    ///
4514    /// Outside the sweep by construction, not by omission: the FDS (mapper
4515    /// 20) and NSF players do not come through `parse` (they load through
4516    /// `Nes::from_disk` / `Nes::from_nsf`), and both write their RAM into
4517    /// their own blobs (the `save_state` of `fds.rs` and `nsf.rs`).
4518    ///
4519    /// What it cannot see: PRG-RAM a board neither exposes through `sram()`
4520    /// nor maps at `$6000-$7FFF` at power-on (RAM behind a board-specific
4521    /// enable register), and CHR-RAM beyond the 8 KiB visible at power-on.
4522    /// The `--nocapture` output lists which boards were checked where.
4523    #[test]
4524    fn every_board_snapshot_carries_cartridge_ram() {
4525        use alloc::collections::{BTreeMap, BTreeSet};
4526
4527        let mut supported: BTreeSet<u16> = BTreeSet::new();
4528        let mut built: BTreeSet<u16> = BTreeSet::new();
4529        let mut checked: BTreeMap<u16, BTreeSet<RamSite>> = BTreeMap::new();
4530        let mut failures: BTreeMap<(u16, RamSite), Vec<String>> = BTreeMap::new();
4531        let mut refusals: BTreeMap<u16, Vec<String>> = BTreeMap::new();
4532
4533        for mapper_id in 0u16..4096 {
4534            let mut headers = Vec::new();
4535            if mapper_id < 256 {
4536                headers.push(SweepHeader::Ines1);
4537            }
4538            headers.extend((0u8..16).map(|submapper| SweepHeader::Nes2 { submapper }));
4539            'headers: for header in headers {
4540                for chr_ram in [false, true] {
4541                    let mut nes = match sweep_boot(mapper_id, header, chr_ram) {
4542                        // An unknown id: no other header builds it either.
4543                        SweepBoot::Unsupported => break 'headers,
4544                        SweepBoot::Refused(e) => {
4545                            supported.insert(mapper_id);
4546                            refusals
4547                                .entry(mapper_id)
4548                                .or_default()
4549                                .push(alloc::format!("{header:?} chr_ram={chr_ram}: {e}"));
4550                            continue;
4551                        }
4552                        SweepBoot::Built(nes) => nes,
4553                    };
4554                    supported.insert(mapper_id);
4555                    built.insert(mapper_id);
4556                    let tag = alloc::format!("{header:?} chr_ram={chr_ram}");
4557                    for (site, fail) in [sweep_prg_ram(&mut nes), sweep_chr_ram(&mut nes)] {
4558                        let Some(site) = site else { continue };
4559                        checked.entry(mapper_id).or_default().insert(site);
4560                        if let Some(fail) = fail {
4561                            failures
4562                                .entry((mapper_id, site))
4563                                .or_default()
4564                                .push(alloc::format!("{tag}: {fail}"));
4565                        }
4566                    }
4567                }
4568            }
4569        }
4570
4571        sweep_print_report(&supported, &built, &checked, &refusals);
4572
4573        // Nothing is skipped silently: an id that never built is either on
4574        // the explicit list, with its reason, or a failure.
4575        let unbuildable: Vec<u16> = supported.difference(&built).copied().collect();
4576        let listed: Vec<u16> = SWEEP_UNBUILDABLE.iter().map(|&(m, _)| m).collect();
4577        assert_eq!(
4578            unbuildable, listed,
4579            "mapper ids that no sweep configuration builds must be listed in \
4580             SWEEP_UNBUILDABLE with a reason: {refusals:?}"
4581        );
4582
4583        // The sweep must keep covering what AUD-02 pinned by name (PRG-RAM on
4584        // the VRC boards 21-26/73/85, CHR-RAM on 21-26/85) and the boards its
4585        // follow-up fixed, or a regression in the probes could pass by
4586        // checking nothing.
4587        for m in [10u16, 21, 22, 23, 24, 25, 26, 73, 85] {
4588            assert!(
4589                checked
4590                    .get(&m)
4591                    .is_some_and(|s| s.contains(&RamSite::PrgSram)),
4592                "mapper {m}: PRG-RAM no longer checked through sram()"
4593            );
4594        }
4595        for m in [
4596            9u16, 10, 11, 19, 21, 22, 23, 24, 25, 26, 34, 69, 75, 85, 151,
4597        ] {
4598            assert!(
4599                checked
4600                    .get(&m)
4601                    .is_some_and(|s| s.contains(&RamSite::ChrRam)),
4602                "mapper {m}: CHR-RAM no longer checked"
4603            );
4604        }
4605
4606        let report: Vec<String> = failures
4607            .iter()
4608            .map(|((m, site), v)| {
4609                alloc::format!("mapper {m} {site:?}: {} config(s), e.g. {}", v.len(), v[0])
4610            })
4611            .collect();
4612        assert!(
4613            failures.is_empty(),
4614            "cartridge RAM not restored from the snapshot on {} board/site pair(s):\n{}",
4615            failures.len(),
4616            report.join("\n")
4617        );
4618    }
4619
4620    #[test]
4621    fn snapshot_is_deterministic_across_two_runs() {
4622        let rom = synth_nrom(16, 8);
4623        let mut a = Nes::from_rom(&rom).unwrap();
4624        let mut b = Nes::from_rom(&rom).unwrap();
4625        for _ in 0..3 {
4626            a.run_frame();
4627            b.run_frame();
4628        }
4629        assert_eq!(a.snapshot(), b.snapshot());
4630    }
4631
4632    #[test]
4633    fn snapshot_header_carries_rom_hash_tag() {
4634        let rom = synth_nrom(16, 8);
4635        let nes = Nes::from_rom(&rom).unwrap();
4636        let blob = nes.snapshot();
4637        let (h, _off) = save_state::parse_header(&blob).unwrap();
4638        assert_eq!(h.rom_hash_tag, nes.rom_hash_tag());
4639    }
4640
4641    /// v2.9.8 (ADR 0042): a `.rns` written by v2.9.7 or earlier carries
4642    /// container format 2 and is refused at the header with one typed error,
4643    /// before any section is looked at -- and the machine is left as it was.
4644    #[test]
4645    fn a_v2_9_7_container_is_refused_at_the_header() {
4646        let rom = synth_nrom(16, 8);
4647        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4648        nes.run_frame();
4649        let mut old = nes.snapshot();
4650        let before = old.clone();
4651        // The container's format version is the u16 after the 8-byte magic.
4652        old[8..10].copy_from_slice(&2u16.to_le_bytes());
4653        let err = nes.restore(&old).unwrap_err();
4654        assert!(
4655            matches!(err, SnapshotError::FormatTooOld { got: 2, .. }),
4656            "expected FormatTooOld, got {err:?}"
4657        );
4658        assert_eq!(nes.snapshot(), before, "a refused load changed the machine");
4659    }
4660
4661    #[test]
4662    fn restore_rejects_pre_v3_cpu_section_version() {
4663        // ADR 0028 (v2.0.0 rc.1): a slot file whose CPU section predates the
4664        // one-clock promote (schema version < CPU_SNAPSHOT_VERSION) must be
4665        // cleanly rejected via SnapshotError::VersionMismatch, not silently
4666        // accepted or upconverted. Simulate an old slot by taking a
4667        // freshly-emitted (current-version) snapshot and patching only the
4668        // CPU section's version byte down to a stale value -- everything
4669        // else (the body bytes, every other section) is untouched, so this
4670        // isolates the version-gate behavior from any layout difference.
4671        let rom = synth_nrom(16, 8);
4672        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4673        for _ in 0..3 {
4674            nes.run_frame();
4675        }
4676        let current = nes.snapshot();
4677        let (_h, body_off) = save_state::parse_header(&current).unwrap();
4678        let mut stale = current[..body_off].to_vec();
4679        for s in save_state::SectionIter::new(&current[body_off..]) {
4680            let s = s.unwrap();
4681            let version = if s.tag == save_state::tag::CPU {
4682                assert_eq!(
4683                    s.version,
4684                    rustynes_cpu::CPU_SNAPSHOT_VERSION,
4685                    "fixture assumption: current build writes the current CPU version"
4686                );
4687                s.version - 1
4688            } else {
4689                s.version
4690            };
4691            save_state::write_section(&mut stale, s.tag, version, s.body);
4692        }
4693        let err = nes.restore(&stale).unwrap_err();
4694        assert!(
4695            matches!(
4696                err,
4697                SnapshotError::VersionMismatch { ref tag, .. } if tag == "CPU "
4698            ),
4699            "expected a CPU-tagged VersionMismatch, got {err:?}"
4700        );
4701    }
4702
4703    /// v3.0.0: a state from another release fails a section's version
4704    /// check, not the container's (v2.9.9's states carry PPU section 11,
4705    /// where v3.0.0 reads 12), and the message must say which way the
4706    /// versions differ. Until v3.0.0 it gave only the two numbers, which
4707    /// players read as a damaged file.
4708    #[test]
4709    fn a_state_from_another_release_says_older_or_newer() {
4710        let rom = synth_nrom(16, 8);
4711        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4712        nes.run_frame();
4713        let current = nes.snapshot();
4714        let (_h, body_off) = save_state::parse_header(&current).unwrap();
4715        for (delta, words) in [(-1i16, "older release"), (1, "newer release")] {
4716            let mut patched = current[..body_off].to_vec();
4717            for s in save_state::SectionIter::new(&current[body_off..]) {
4718                let s = s.unwrap();
4719                let version = if s.tag == save_state::tag::PPU {
4720                    u8::try_from(i16::from(s.version) + delta).unwrap()
4721                } else {
4722                    s.version
4723                };
4724                save_state::write_section(&mut patched, s.tag, version, s.body);
4725            }
4726            let err = nes.restore(&patched).unwrap_err();
4727            let text = format!("{err}");
4728            assert!(
4729                matches!(err, SnapshotError::VersionMismatch { ref tag, .. } if tag == "PPU "),
4730                "expected a PPU VersionMismatch, got {err:?}"
4731            );
4732            assert!(text.contains(words), "{text}");
4733        }
4734    }
4735
4736    /// v2.7.4 (frontend audit MOB-08): a load that fails leaves the machine as
4737    /// it was. `restore_inner` applies the bus sections first and checks the
4738    /// CPU section after, so on v2.7.3 a blob rejected at the CPU stage left
4739    /// the bus from the blob and the CPU from the running game -- a machine
4740    /// that was neither, which the next frame then emulated. Every host hit
4741    /// it: desktop, libretro and both mobile apps. The rejected blob here is
4742    /// the stale-CPU-version one from the test above, taken at an earlier frame
4743    /// so its bus state really differs.
4744    #[test]
4745    fn a_failed_restore_leaves_the_machine_untouched() {
4746        let rom = synth_nrom(16, 8);
4747        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4748        nes.run_frame();
4749        let earlier = nes.snapshot();
4750        for _ in 0..3 {
4751            nes.run_frame();
4752        }
4753        let before = nes.snapshot();
4754        assert_ne!(
4755            earlier, before,
4756            "the fixture must change state between frames"
4757        );
4758
4759        let (_h, body_off) = save_state::parse_header(&earlier).unwrap();
4760        let mut rejected = earlier[..body_off].to_vec();
4761        for s in save_state::SectionIter::new(&earlier[body_off..]) {
4762            let s = s.unwrap();
4763            let version = if s.tag == save_state::tag::CPU {
4764                s.version - 1
4765            } else {
4766                s.version
4767            };
4768            save_state::write_section(&mut rejected, s.tag, version, s.body);
4769        }
4770        assert!(nes.restore(&rejected).is_err());
4771        assert_eq!(nes.snapshot(), before, "a failed load changed the machine");
4772        // And it still runs as the same machine.
4773        nes.run_frame();
4774    }
4775
4776    /// `blob` with the version byte of its `CPU ` section decremented: a
4777    /// well-framed state that every stage before the CPU one accepts.
4778    fn with_stale_cpu_version(blob: &[u8]) -> Vec<u8> {
4779        let (_h, body_off) = save_state::parse_header(blob).unwrap();
4780        let mut out = blob[..body_off].to_vec();
4781        for s in save_state::SectionIter::new(&blob[body_off..]) {
4782            let s = s.unwrap();
4783            let version = if s.tag == save_state::tag::CPU {
4784                s.version - 1
4785            } else {
4786                s.version
4787            };
4788            save_state::write_section(&mut out, s.tag, version, s.body);
4789        }
4790        out
4791    }
4792
4793    /// `blob` with byte `at` of its `MAP ` section's body set to `value`.
4794    fn with_map_byte(blob: &[u8], at: usize, value: u8) -> Vec<u8> {
4795        let (_h, body_off) = save_state::parse_header(blob).unwrap();
4796        let mut out = blob[..body_off].to_vec();
4797        for s in save_state::SectionIter::new(&blob[body_off..]) {
4798            let s = s.unwrap();
4799            if s.tag == save_state::tag::MAP {
4800                let mut body = s.body.to_vec();
4801                body[at] = value;
4802                save_state::write_section(&mut out, s.tag, s.version, &body);
4803            } else {
4804                save_state::write_section(&mut out, s.tag, s.version, s.body);
4805            }
4806        }
4807        out
4808    }
4809
4810    /// v2.9.0 re-audit NC-02, at the whole-machine level: a GTROM (mapper 111)
4811    /// state carrying a bank the board cannot hold is refused by
4812    /// `Nes::restore`, and the machine keeps running. Before, `restore`
4813    /// returned `Ok(())` and the next CPU fetch from `$8000` panicked
4814    /// (`homebrew_boards.rs:570`, index 8,372,319 into 32 KiB in the
4815    /// re-audit's probe) — after the restore, so its rollback could not help.
4816    #[test]
4817    fn a_gtrom_state_with_an_impossible_bank_is_refused_and_the_game_runs_on() {
4818        let mut rom = synth_nrom(32, 0);
4819        rom[6] = 0xF0; // mapper low nibble F
4820        rom[7] = 0x60; // mapper high nibble 6: 0x6F = 111
4821        let mut donor = Nes::from_rom(&rom).expect("GTROM image");
4822        donor.run_frame();
4823        donor.run_frame();
4824        let good = donor.snapshot();
4825        // MAP body: version, prg_bank, chr_bank, nt_bank, ...
4826        for (at, value) in [(1, 0xFF), (2, 0xFF), (3, 0xFF)] {
4827            let bad = with_map_byte(&good, at, value);
4828            let mut nes = Nes::from_rom(&rom).unwrap();
4829            nes.run_frame();
4830            let before = nes.snapshot();
4831            assert!(
4832                nes.restore(&bad).is_err(),
4833                "MAP byte {at} = {value:#04x} must be refused"
4834            );
4835            assert!(
4836                nes.snapshot() == before,
4837                "a refused load changed the machine"
4838            );
4839            nes.run_frame();
4840            nes.run_frame();
4841        }
4842    }
4843
4844    /// v2.9.0 re-audit NC-03 / NL-01: the QUIET restore is all-or-nothing too.
4845    ///
4846    /// v2.7.4 gave only the loud path a rollback, on the premise that a quiet
4847    /// restore "only ever restore[s] snapshots this core just wrote, which
4848    /// cannot fail these checks". libretro breaks that premise: every
4849    /// `retro_unserialize` -- a user's Load State, a state from an older core,
4850    /// a corrupt file -- goes through `restore_quiet`. The re-audit's probe
4851    /// showed a rejected CPU section leaving the bus, PPU, APU and mapper from
4852    /// the rejected file (frame 3000) under the running game's CPU (frame 5).
4853    ///
4854    /// Two rejection points are pinned, because they fail at different depths:
4855    /// the CPU section's version (after all four bus sections applied) and the
4856    /// cross-section clock check (after the CPU applied as well). Both must
4857    /// leave the machine byte-identical and the rewind ring untouched.
4858    #[test]
4859    fn a_failed_quiet_restore_leaves_the_machine_untouched() {
4860        let rom = synth_nrom(16, 8);
4861        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4862        nes.enable_rewind_with(2 * 1024 * 1024, 1);
4863        nes.run_frame();
4864        nes.run_frame();
4865        let earlier = nes.snapshot();
4866        for _ in 0..3 {
4867            nes.run_frame();
4868        }
4869        let before = nes.snapshot();
4870        let ring_before = nes.rewind.as_ref().map(RewindRing::len);
4871        assert_ne!(
4872            earlier, before,
4873            "the fixture must change state between frames"
4874        );
4875
4876        // A CPU/PPU clock skew the clock check rejects, carried by an
4877        // otherwise valid EARLIER state: every section decodes, so this fails
4878        // at the very last stage.
4879        let skewed = {
4880            let mut other = Nes::from_rom(&rom).unwrap();
4881            other.run_frame();
4882            other.run_frame();
4883            let far = other.cpu.master_clock() + (1 << 40);
4884            other.bus_mut().set_ppu_clock_for_test(far);
4885            other.snapshot()
4886        };
4887
4888        for (label, blob) in [
4889            ("stale CPU version", with_stale_cpu_version(&earlier)),
4890            ("CPU/PPU clock skew", skewed),
4891        ] {
4892            assert!(nes.restore_quiet(&blob).is_err(), "{label}: must reject");
4893            // `assert!` rather than `assert_eq!`: a failure would otherwise
4894            // print two ~300 KB byte arrays.
4895            assert!(
4896                nes.snapshot() == before,
4897                "{label}: a failed quiet restore changed the machine"
4898            );
4899            assert_eq!(
4900                nes.rewind.as_ref().map(RewindRing::len),
4901                ring_before,
4902                "{label}: a quiet restore must leave the rewind ring alone"
4903            );
4904        }
4905        // And it still runs as the same machine.
4906        nes.run_frame();
4907    }
4908
4909    #[test]
4910    fn rewind_step_back_restores_prior_frame() {
4911        let rom = synth_nrom(16, 8);
4912        let mut nes = Nes::from_rom(&rom).unwrap();
4913        nes.enable_rewind_with(2 * 1024 * 1024, 1);
4914        for _ in 0..6 {
4915            nes.run_frame();
4916        }
4917        let cycle_at_6 = nes.cycle();
4918        nes.run_frame();
4919        nes.run_frame();
4920        nes.run_frame();
4921        // 3 entries on the ring (frames 6..=8 captured at the END of each
4922        // run_frame — frame 5 was captured in the loop above).
4923        assert!(nes.rewind_step_back(), "first step back");
4924        assert!(nes.rewind_step_back(), "second step back");
4925        assert!(nes.rewind_step_back(), "third step back");
4926        // We've rewound past the 3 extra frames; cycle should equal the
4927        // state we captured at the end of frame 6 (i.e. frame 5's snap).
4928        assert_ne!(nes.cycle(), cycle_at_6, "captured frame 5, not frame 6");
4929    }
4930
4931    // ---- v2.4.0 item B — the timeline generation counter ----
4932
4933    /// A LOUD restore is a timeline jump and must bump the generation.
4934    ///
4935    /// This is the defect the counter exists for: v2.3.9 cleared stale debug
4936    /// telemetry on a ROM change and could not clear it on a save-state load,
4937    /// because two of the four jump paths are not reachable from a patchable
4938    /// frontend call site.
4939    #[test]
4940    fn a_loud_restore_bumps_the_timeline_generation() {
4941        let rom = synth_nrom(16, 8);
4942        let mut nes = Nes::from_rom(&rom).unwrap();
4943        nes.run_frame();
4944        let blob = nes.snapshot();
4945        let before = nes.timeline_generation();
4946        nes.run_frame();
4947        nes.restore(&blob).expect("restore");
4948        assert!(
4949            nes.timeline_generation() > before,
4950            "a user-driven load did not register as a timeline jump"
4951        );
4952    }
4953
4954    /// A QUIET restore is the SAME timeline and must NOT bump.
4955    ///
4956    /// Run-ahead restores every frame and netplay rollback restores on every
4957    /// correction. Bumping here would clear a consumer's telemetry sixty times a
4958    /// second — worse than the stale-telemetry defect the counter fixes.
4959    #[test]
4960    fn a_quiet_restore_does_not_bump_the_timeline_generation() {
4961        let rom = synth_nrom(16, 8);
4962        let mut nes = Nes::from_rom(&rom).unwrap();
4963        nes.run_frame();
4964        let blob = nes.snapshot();
4965        let before = nes.timeline_generation();
4966        nes.run_frame();
4967        nes.restore_quiet(&blob).expect("quiet restore");
4968        assert_eq!(
4969            nes.timeline_generation(),
4970            before,
4971            "a same-timeline restore was reported as a jump; under run-ahead this \
4972             fires every frame"
4973        );
4974    }
4975
4976    /// Rewind is a jump, and it reaches the counter without its own call site.
4977    #[test]
4978    fn rewind_bumps_the_timeline_generation() {
4979        let rom = synth_nrom(16, 8);
4980        let mut nes = Nes::from_rom(&rom).unwrap();
4981        nes.enable_rewind_with(2 * 1024 * 1024, 1);
4982        for _ in 0..4 {
4983            nes.run_frame();
4984        }
4985        let before = nes.timeline_generation();
4986        assert!(nes.rewind_step_back(), "step back");
4987        assert!(
4988            nes.timeline_generation() > before,
4989            "rewind did not register as a timeline jump"
4990        );
4991    }
4992
4993    /// Reset and power-cycle are discontinuities too.
4994    #[test]
4995    fn reset_and_power_cycle_bump_the_timeline_generation() {
4996        let rom = synth_nrom(16, 8);
4997        let mut nes = Nes::from_rom(&rom).unwrap();
4998        let a = nes.timeline_generation();
4999        nes.reset();
5000        let b = nes.timeline_generation();
5001        assert!(b > a, "warm reset did not bump");
5002        nes.power_cycle();
5003        assert!(nes.timeline_generation() > b, "power cycle did not bump");
5004    }
5005
5006    /// v2.9.8 — a power cycle keeps every host setting stored on the PPU and
5007    /// the APU, on a bare `Nes` with no host to re-push anything.
5008    ///
5009    /// `Nes::power_cycle` rebuilds both chips, and until v2.9.8 each of these
5010    /// reverted to its default there: the desktop re-pushed them after the
5011    /// cycle, the movie power-on (`power_on_for_movie`), libretro and mobile
5012    /// paths did not. One row per setting, each set to a NON-default value,
5013    /// so a row that reverts fails on its own.
5014    #[test]
5015    fn a_power_cycle_keeps_every_ppu_and_apu_setting() {
5016        type Setting = (&'static str, fn(&mut Nes), fn(&Nes) -> bool);
5017        const PAL: [[u8; 3]; 64] = [[0x12, 0x34, 0x56]; 64];
5018        const GAIN: [f32; 6] = [0.5, 1.5, 0.25, 2.0, 0.0, 0.75];
5019        let rows: [Setting; 7] = [
5020            (
5021                "custom palette",
5022                |n| n.set_custom_palette(Some(PAL)),
5023                |n| n.custom_palette() == Some(PAL),
5024            ),
5025            (
5026                "extra scanlines",
5027                |n| n.set_extra_scanlines(7),
5028                |n| n.extra_scanlines() == 7,
5029            ),
5030            (
5031                "fast dot path (off; on is the default)",
5032                |n| n.set_fast_dotloop(false),
5033                |n| !n.fast_dotloop(),
5034            ),
5035            (
5036                "OAM decay",
5037                |n| n.set_oam_decay(true),
5038                |n| n.oam_decay_enabled(),
5039            ),
5040            (
5041                "APU channel mask",
5042                |n| n.set_apu_channel_mask(0x15),
5043                |n| n.apu_channel_mask() == 0x15,
5044            ),
5045            (
5046                "APU channel gain",
5047                |n| n.set_apu_channel_gain(GAIN),
5048                |n| n.apu_channel_gain() == GAIN,
5049            ),
5050            (
5051                "APU filter model",
5052                |n| n.set_apu_filter_model(rustynes_apu::FilterModel::Famicom),
5053                |n| n.apu_filter_model() == rustynes_apu::FilterModel::Famicom,
5054            ),
5055        ];
5056        let rom = synth_nrom(16, 8);
5057        let mut lost = Vec::new();
5058        for (name, set, holds) in rows {
5059            let mut nes = Nes::from_rom(&rom).unwrap();
5060            assert!(!holds(&nes), "{name}: premise -- the default differs");
5061            set(&mut nes);
5062            assert!(holds(&nes), "{name}: the setter applies it");
5063            nes.run_frame();
5064            nes.power_cycle();
5065            if !holds(&nes) {
5066                lost.push(name);
5067            }
5068        }
5069        // Every row is checked before failing, so a regression names every
5070        // setting it drops rather than only the first.
5071        assert!(lost.is_empty(), "lost across a power cycle: {lost:?}");
5072    }
5073
5074    /// v2.9.8 — the kept settings are not just remembered, they are in force:
5075    /// a power-cycled console with them set produces the frame and the audio a
5076    /// FRESH console with the same settings produces (`power_cycle == fresh
5077    /// boot`, with the host's configuration held constant). The palette and
5078    /// the filter change what is emitted, so a setting that was only stored,
5079    /// and not applied to the rebuilt chip, fails here.
5080    ///
5081    /// The channel gain and the OAM-decay model are left out on purpose, and
5082    /// are covered by the table above instead: a FRESH console can only
5083    /// receive a setting after `from_rom`'s reset sequence has run its first
5084    /// cycles at the default, whereas the power-cycled console runs those
5085    /// cycles with the setting already in force. For the gain that moves the
5086    /// last bits of the audio; for OAM decay, enabling the model stamps every
5087    /// row's age with the current cycle, which is 0 on the rebuilt PPU and a
5088    /// few cycles later on the fresh one, so the serialized ages differ. Both
5089    /// differences are the fix working. (Measured for the gain: applying it
5090    /// to both consoles after the cycle matches, carrying it through does not.
5091    /// For decay: the test passes with it removed and fails with it present.)
5092    #[test]
5093    fn a_power_cycled_console_with_settings_runs_as_a_fresh_one() {
5094        fn configure(n: &mut Nes) {
5095            n.set_custom_palette(Some([[0x40, 0x80, 0xC0]; 64]));
5096            n.set_apu_filter_model(rustynes_apu::FilterModel::Clean);
5097        }
5098        let rom = include_bytes!("../../../tests/roms/nestest/nestest.nes");
5099        let mut cycled = Nes::from_rom(rom).unwrap();
5100        configure(&mut cycled);
5101        for _ in 0..5 {
5102            cycled.run_frame();
5103        }
5104        let _ = cycled.drain_audio();
5105        cycled.power_cycle();
5106        let mut fresh = Nes::from_rom(rom).unwrap();
5107        configure(&mut fresh);
5108        for _ in 0..10 {
5109            cycled.run_frame();
5110            fresh.run_frame();
5111        }
5112        assert_eq!(cycled.framebuffer(), fresh.framebuffer(), "frame");
5113        assert_eq!(cycled.drain_audio(), fresh.drain_audio(), "audio");
5114        assert_eq!(cycled.snapshot(), fresh.snapshot(), "state");
5115    }
5116
5117    /// v2.9.8 — the provenance stores stay armed across a power cycle (and
5118    /// are emptied, since a cold boot ends the history they describe), as the
5119    /// comment in `Nes::power_cycle` always said. They were dropped with the
5120    /// rebuilt chips until v2.9.8, which made that comment's clear a no-op.
5121    #[cfg(feature = "debug-hooks")]
5122    #[test]
5123    fn a_power_cycle_keeps_the_provenance_stores_armed() {
5124        let rom = synth_nrom(16, 8);
5125        let mut nes = Nes::from_rom(&rom).unwrap();
5126        nes.set_write_attribution(true);
5127        nes.set_pixel_provenance(true);
5128        nes.set_audio_provenance(true);
5129        nes.run_frame();
5130        nes.power_cycle();
5131        assert!(
5132            nes.bus.ppu.write_attribution().is_some(),
5133            "write attribution"
5134        );
5135        assert!(nes.bus.ppu.pixel_provenance().is_some(), "pixel provenance");
5136        assert!(nes.bus.apu.audio_provenance_armed(), "audio provenance");
5137    }
5138
5139    /// v2.9.8 — a power cycle after a program has polled the controllers is
5140    /// a fresh boot (`power_cycle == fresh boot`).
5141    ///
5142    /// The controller ports remember the bus cycle of their last read
5143    /// (`port_read_cycle`, `u64::MAX` = never), which the CLK-run model
5144    /// compares with the current cycle. A power cycle reset the cycle counter
5145    /// to 0 but left those stamps from the old timeline, so the cycled
5146    /// console's state depended on how long it had run and differed from a
5147    /// fresh one; two netplay peers cycling from different states would carry
5148    /// different stamps.
5149    #[test]
5150    fn a_power_cycle_after_controller_reads_is_a_fresh_boot() {
5151        let mut rom = synth_nrom(16, 8);
5152        // $C000: LDA $4016 ; LDA $4017 ; JMP $C000
5153        rom[16..25].copy_from_slice(&[0xAD, 0x16, 0x40, 0xAD, 0x17, 0x40, 0x4C, 0x00, 0xC0]);
5154        let mut cycled = Nes::from_rom(&rom).unwrap();
5155        for _ in 0..3 {
5156            cycled.run_frame();
5157        }
5158        assert_ne!(
5159            cycled.bus.port_read_cycle(0),
5160            u64::MAX,
5161            "premise: the program polled the pad"
5162        );
5163        cycled.power_cycle();
5164        let fresh = Nes::from_rom(&rom).unwrap();
5165        assert!(
5166            cycled.snapshot() == fresh.snapshot(),
5167            "a power-cycled console must equal a fresh one"
5168        );
5169    }
5170
5171    /// v2.9.8 — `mapper_id` reports the cartridge's mapper on every board.
5172    ///
5173    /// It used to read the mapper's DEBUG view, whose default `debug_info`
5174    /// names mapper 0, so every board without its own override -- `UxROM`,
5175    /// CNROM, `AxROM` among them -- claimed to be NROM to the Lua
5176    /// `cart:mapper_id()`, the ROM-info panel and the mobile `RomInfo`.
5177    #[test]
5178    fn mapper_id_reports_the_cartridge_mapper_on_every_board() {
5179        /// A NES 2.0 image of `mapper` / `submapper`: `prg16` x 16 KiB PRG and
5180        /// `chr8` x 8 KiB CHR-ROM (0 = 8 KiB CHR-RAM).
5181        fn image(mapper: u16, submapper: u8, prg16: u8, chr8: u8) -> Vec<u8> {
5182            let [lo, hi] = mapper.to_le_bytes();
5183            let mut rom = vec![0u8; 16];
5184            rom[..4].copy_from_slice(b"NES\x1A");
5185            rom[4] = prg16;
5186            rom[5] = chr8;
5187            rom[6] = (lo & 0x0F) << 4;
5188            rom[7] = (lo & 0xF0) | 0x08; // NES 2.0
5189            rom[8] = (submapper << 4) | (hi & 0x0F);
5190            if chr8 == 0 {
5191                rom[11] = 0x07; // 64 << 7 = 8 KiB CHR-RAM
5192            }
5193            rom.resize(
5194                16 + usize::from(prg16) * 0x4000 + usize::from(chr8) * 0x2000,
5195                0,
5196            );
5197            rom
5198        }
5199        for (mapper, submapper, prg16, chr8) in [
5200            (0u16, 0u8, 2u8, 1u8),
5201            (2, 1, 8, 0),
5202            (3, 2, 2, 4),
5203            (7, 0, 8, 0),
5204            (4, 0, 8, 8),
5205        ] {
5206            let nes = Nes::from_rom(&image(mapper, submapper, prg16, chr8))
5207                .unwrap_or_else(|e| panic!("mapper {mapper}: {e:?}"));
5208            assert_eq!(nes.mapper_id(), mapper, "mapper {mapper}");
5209            assert_eq!(nes.submapper(), submapper, "mapper {mapper} submapper");
5210            // The debugger's mapper panel reads the same id.
5211            assert_eq!(nes.mapper_info().mapper_id, mapper, "mapper {mapper} view");
5212        }
5213    }
5214
5215    /// **The counter must not be serialized**, and this is the assertion that
5216    /// pins it.
5217    ///
5218    /// Serializing it would put an OLD value back on restore, so loading a state
5219    /// saved earlier in the same session could hand a consumer a generation it
5220    /// has already seen — and the consumer would conclude nothing jumped at the
5221    /// exact moment something did. This test reproduces precisely that shape:
5222    /// snapshot at generation N, advance the generation past N, then restore. If
5223    /// the counter round-tripped, the value would come back as N.
5224    #[test]
5225    fn a_restore_never_hands_back_a_generation_a_consumer_has_seen() {
5226        let rom = synth_nrom(16, 8);
5227        let mut nes = Nes::from_rom(&rom).unwrap();
5228        nes.run_frame();
5229        let blob = nes.snapshot();
5230        let at_snapshot = nes.timeline_generation();
5231
5232        // Advance the generation well past the snapshot's value.
5233        for _ in 0..3 {
5234            nes.reset();
5235        }
5236        let seen = nes.timeline_generation();
5237        assert!(seen > at_snapshot);
5238
5239        nes.restore(&blob).expect("restore");
5240        assert!(
5241            nes.timeline_generation() > seen,
5242            "the generation went BACKWARDS to {} (a consumer had already seen {seen}), \
5243             so the counter is being carried in the save state -- which defeats its \
5244             only purpose",
5245            nes.timeline_generation()
5246        );
5247    }
5248
5249    #[test]
5250    fn rewind_disabled_no_op() {
5251        let rom = synth_nrom(16, 8);
5252        let mut nes = Nes::from_rom(&rom).unwrap();
5253        nes.run_frame();
5254        assert!(!nes.rewind_step_back());
5255        assert_eq!(nes.rewind_len(), 0);
5256    }
5257
5258    #[cfg(feature = "debug-hooks")]
5259    #[test]
5260    fn breakpoint_stops_run_frame_at_pc() {
5261        let rom = synth_nrom(16, 8);
5262        // A PC the CPU provably reaches: the PC after the first 3 executed
5263        // instructions (a fresh run replays the same deterministic sequence).
5264        let mut probe = Nes::from_rom(&rom).expect("parse");
5265        for _ in 0..3 {
5266            probe.step_instruction();
5267        }
5268        let target = probe.cpu.pc;
5269
5270        let mut nes = Nes::from_rom(&rom).expect("parse");
5271        // The PPU's frame-complete latch is set at power-on, so the first
5272        // `run_frame` returns immediately without iterating; warm past it.
5273        let _ = nes.run_frame();
5274        nes.add_breakpoint(target);
5275        nes.add_breakpoint(target); // idempotent
5276        assert_eq!(nes.breakpoints(), &[target]);
5277
5278        let _ = nes.run_frame();
5279        assert_eq!(
5280            nes.take_break_hit(),
5281            Some(target),
5282            "stops at the breakpoint PC"
5283        );
5284        assert_eq!(nes.take_break_hit(), None, "hit cleared on read");
5285
5286        // Resuming ("continue") steps past the stopped PC instead of
5287        // re-breaking in place, then hits the same PC again next loop.
5288        let _ = nes.run_frame();
5289        assert_eq!(
5290            nes.take_break_hit(),
5291            Some(target),
5292            "resume steps past, then re-hits on the next pass"
5293        );
5294
5295        // Disarmed breakpoints don't fire (the frame runs to completion).
5296        nes.set_breakpoints_enabled(false);
5297        let _ = nes.run_frame();
5298        assert_eq!(nes.take_break_hit(), None, "disarmed: no break");
5299
5300        // Removal empties the list.
5301        nes.remove_breakpoint(target);
5302        assert_eq!(nes.breakpoints(), []);
5303
5304        // Regression (gemini #41): a breakpoint sitting at the frame's STARTING
5305        // PC must fire immediately — the old `first_iter` skip missed it.
5306        let mut nes2 = Nes::from_rom(&rom).expect("parse");
5307        let _ = nes2.run_frame(); // warm past the power-on frame-complete latch
5308        let start_pc = nes2.cpu.pc;
5309        nes2.add_breakpoint(start_pc);
5310        let _ = nes2.run_frame();
5311        assert_eq!(
5312            nes2.take_break_hit(),
5313            Some(start_pc),
5314            "breaks immediately when starting already on a breakpoint"
5315        );
5316    }
5317
5318    #[cfg(feature = "debug-hooks")]
5319    #[test]
5320    fn trace_logger_records_while_enabled() {
5321        let rom = synth_nrom(16, 8);
5322        let mut nes = Nes::from_rom(&rom).expect("parse");
5323        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
5324        assert_eq!(nes.trace_len(), 0, "off by default");
5325        nes.set_trace_enabled(true);
5326        let _ = nes.run_frame();
5327        assert!(nes.trace_len() > 0, "records while enabled");
5328        // Records carry the executed PCs (the synth ROM spins at $C000).
5329        let recs = nes.trace_records();
5330        assert!(recs.iter().any(|r| r.pc == 0xC000), "captured the loop PC");
5331        // The tail copy is bounded.
5332        assert!(nes.trace_tail_vec(4).len() <= 4);
5333        // Disabling stops growth; clearing empties.
5334        nes.set_trace_enabled(false);
5335        let n = nes.trace_len();
5336        let _ = nes.run_frame();
5337        assert_eq!(nes.trace_len(), n, "no new records when disabled");
5338        nes.clear_trace();
5339        assert_eq!(nes.trace_len(), 0);
5340    }
5341
5342    #[cfg(feature = "debug-hooks")]
5343    #[test]
5344    fn event_viewer_records_writes_with_ppu_position() {
5345        use crate::bus::EventKind;
5346        // A tiny NROM that loops `LDA #$00 ; STA $2000 ; JMP $C000`, so it
5347        // generates a PPU-register write ($2000) every iteration.
5348        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
5349        bytes[0..4].copy_from_slice(b"NES\x1A");
5350        bytes[4] = 1; // 1x16KB PRG
5351        bytes[5] = 1; // 1x8KB CHR (unused here)
5352        let prg = &mut bytes[16..16 + 16 * 1024];
5353        // $C000 maps to PRG offset 0.
5354        prg[0..8].copy_from_slice(&[0xA9, 0x00, 0x8D, 0x00, 0x20, 0x4C, 0x00, 0xC0]);
5355        let len = 16 * 1024;
5356        prg[len - 4] = 0x00; // reset vector lo
5357        prg[len - 3] = 0xC0; // reset vector hi -> $C000
5358        // CHR not appended (header says 1 bank but parse tolerates; use 0 banks).
5359        bytes[5] = 0;
5360
5361        let mut nes = Nes::from_rom(&bytes).expect("parse");
5362        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
5363        assert!(nes.events().is_empty(), "off by default");
5364        nes.set_event_logging(true);
5365        let _ = nes.run_frame();
5366        let evs = nes.events();
5367        assert!(!evs.is_empty(), "the STA $2000 loop produces writes");
5368        assert!(
5369            evs.iter().all(|e| e.kind == EventKind::PpuWrite
5370                && e.addr == 0x2000
5371                && e.dot <= 340
5372                && e.value == 0x00),
5373            "all events are $2000 PPU writes of $00 with a sane dot"
5374        );
5375        // Reset per frame: the count stays one-frame-bounded. The event log is
5376        // capped at `EVENT_CAP` (20_000, private to the bus module) — distinct
5377        // from the looser instruction-trace `TRACE_CAP` — so assert that bound.
5378        let _ = nes.run_frame();
5379        assert!(nes.events().len() <= 20_000, "bounded by EVENT_CAP");
5380        nes.set_event_logging(false);
5381        assert!(!nes.event_logging());
5382    }
5383
5384    /// v2.3.2 "Lucid" Phase 1 — the write-attribution oracle.
5385    ///
5386    /// The claim under test is the whole point of the feature: for a byte in the
5387    /// PPU's own memory, the store reports the PC of the instruction that put it
5388    /// there. The ROM is written so the answer is known independently — a single
5389    /// `STA $2007` at a fixed address — and the expectation is pinned to that
5390    /// address rather than to whatever the implementation happens to record.
5391    #[cfg(feature = "debug-hooks")]
5392    #[test]
5393    fn write_attribution_names_the_instruction_that_wrote_a_nametable_byte() {
5394        // NROM at $C000:
5395        //   C000: A9 21     LDA #$21        ; VRAM addr hi
5396        //   C002: 8D 06 20  STA $2006
5397        //   C005: A9 08     LDA #$08        ; VRAM addr lo -> $2108
5398        //   C007: 8D 06 20  STA $2006
5399        //   C00A: A9 5A     LDA #$5A        ; the byte
5400        //   C00C: 8D 07 20  STA $2007       <-- the write under test
5401        //   C00F: 4C 00 C0  JMP $C000       ; loop the whole sequence
5402        //
5403        // The sequence LOOPS rather than spinning after one pass, and the test
5404        // runs several frames, because the PPU ignores `$2000/$2001/$2005/$2006`
5405        // writes for ~29,658 CPU cycles after reset (the documented post-reset
5406        // mask window, `PpuRegion::post_reset_mask_cycles`). A single pass at
5407        // power-on would have its two `$2006` stores dropped, leaving `v == 0`,
5408        // and the `$2007` write would land in CHR space instead of a nametable.
5409        const STA_2007_PC: u16 = 0xC00C;
5410        const VRAM_ADDR: u16 = 0x2108;
5411        const VALUE: u8 = 0x5A;
5412
5413        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
5414        bytes[0..4].copy_from_slice(b"NES\x1A");
5415        bytes[4] = 1; // 1x16KB PRG
5416        bytes[5] = 0; // no CHR bank appended
5417        let prg = &mut bytes[16..16 + 16 * 1024];
5418        prg[0..18].copy_from_slice(&[
5419            0xA9, 0x21, // LDA #$21
5420            0x8D, 0x06, 0x20, // STA $2006
5421            0xA9, 0x08, // LDA #$08
5422            0x8D, 0x06, 0x20, // STA $2006
5423            0xA9, VALUE, // LDA #$5A
5424            0x8D, 0x07, 0x20, // STA $2007
5425            0x4C, 0x00, 0xC0, // JMP $C000
5426        ]);
5427        let len = 16 * 1024;
5428        prg[len - 4] = 0x00; // reset vector lo
5429        prg[len - 3] = 0xC0; // reset vector hi -> $C000
5430
5431        let mut nes = Nes::from_rom(&bytes).expect("parse");
5432        assert!(
5433            nes.write_attribution().is_none(),
5434            "attribution is off by default"
5435        );
5436        nes.set_write_attribution(true);
5437        // Three frames: one to clear the post-reset write-mask window, the rest
5438        // so the loop's `$2006`/`$2007` sequence lands for real.
5439        for _ in 0..3 {
5440            let _ = nes.run_frame();
5441        }
5442
5443        // Resolve the address the way the emulator does, through the mapper's
5444        // mirroring, rather than hardcoding `& 0x07FF`. The previous version of
5445        // this line claimed to do that and then hardcoded it anyway (review
5446        // catch on PR #356) — which would have masked a mirroring regression.
5447        let off = nes
5448            .ciram_offset_for_nametable_addr(VRAM_ADDR)
5449            .expect("a nametable address resolves to a CIRAM offset");
5450        let attrib = nes.write_attribution().expect("armed");
5451        let rec = attrib
5452            .ciram(off)
5453            .expect("the STA $2007 wrote this CIRAM byte");
5454        assert_eq!(
5455            rec.pc, STA_2007_PC,
5456            "the byte is attributed to the STA $2007, not to the $2006 stores \
5457             that set the address or to the LDA that loaded the value"
5458        );
5459        assert_eq!(rec.value, VALUE);
5460        // The byte really is there — attribution must describe a write that
5461        // actually happened, not a write the tap merely observed being issued.
5462        assert_eq!(nes.vram()[off], VALUE);
5463        // And the cycle stamp is a real one from this run.
5464        assert!(rec.cycle > 0, "cycle stamp taken from the executing CPU");
5465
5466        // An untouched byte reports nothing rather than a plausible-looking zero.
5467        assert_eq!(attrib.ciram(off ^ 0x0400), None);
5468
5469        // Disarming frees the store; re-arming starts clean.
5470        nes.set_write_attribution(false);
5471        assert!(nes.write_attribution().is_none());
5472    }
5473
5474    /// v2.3.2 "Lucid" phase 2 — the per-pixel provenance oracle.
5475    ///
5476    /// Builds a screen out of a known nametable byte and a known palette, then
5477    /// checks that the record for a background pixel names the addresses that
5478    /// actually produced it. The load-bearing assertion is `nt_addr`: it must be
5479    /// the address of the tile ON SCREEN, which is two tiles behind whatever `v`
5480    /// holds at emit time — the single mistake this whole cascade exists to
5481    /// prevent.
5482    #[cfg(feature = "debug-hooks")]
5483    #[test]
5484    fn pixel_provenance_names_the_displayed_tile_not_the_fetch_pointer() {
5485        use rustynes_ppu::PixelLayer;
5486
5487        // NROM with CHR-RAM. The program:
5488        //   * fills nametable $2000 with tile $01,
5489        //   * writes a non-zero pattern for tile $01 into CHR-RAM,
5490        //   * sets palette entry $3F01 to a known color,
5491        //   * enables background rendering,
5492        //   * spins.
5493        //
5494        // Assembled by hand below; addresses are named so the assertions can
5495        // reference the instruction rather than a magic number.
5496        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
5497        bytes[0..4].copy_from_slice(b"NES\x1A");
5498        bytes[4] = 1; // 1x16KB PRG
5499        bytes[5] = 0; // 0 CHR banks => CHR-RAM, so the pattern is writable
5500        let prg = &mut bytes[16..16 + 16 * 1024];
5501
5502        #[rustfmt::skip]
5503        let code: &[u8] = &[
5504            // --- Delay ~328k cycles (~11 frames) BEFORE touching any PPU
5505            // register. The PPU ignores $2000/$2001/$2005/$2006 writes for
5506            // ~29,658 CPU cycles after reset; setup that runs inside that window
5507            // has its $2006 address writes silently dropped, so every subsequent
5508            // $2007 lands somewhere unintended. Found by this test failing.
5509            0xA2, 0x00,                         // C000 LDX #$00
5510            0xA0, 0x00,                         // C002 LDY #$00
5511            0x88,                               // C004 DEY
5512            0xD0, 0xFD,                         // C005 BNE $C004
5513            0xCA,                               // C007 DEX
5514            0xD0, 0xF8,                         // C008 BNE $C002
5515            // --- CHR-RAM: tile $01 rows 0..7 low plane = $FF (all pixels idx 1)
5516            0xA9, 0x00, 0x8D, 0x06, 0x20,       // C00A LDA #$00 / STA $2006
5517            0xA9, 0x10, 0x8D, 0x06, 0x20,       // C00F LDA #$10 / STA $2006  -> $0010
5518            0xA2, 0x08,                         // C014 LDX #$08
5519            0xA9, 0xFF,                         // C016 LDA #$FF
5520            0x8D, 0x07, 0x20,                   // C018 STA $2007  (8x low plane)
5521            0xCA, 0xD0, 0xFA,                   // C01B DEX / BNE $C018
5522            // --- palette: $3F00 = $0F (black), $3F01 = $16 (red)
5523            0xA9, 0x3F, 0x8D, 0x06, 0x20,       // C01E LDA #$3F / STA $2006
5524            0xA9, 0x00, 0x8D, 0x06, 0x20,       // C023 LDA #$00 / STA $2006  -> $3F00
5525            0xA9, 0x0F, 0x8D, 0x07, 0x20,       // C028 LDA #$0F / STA $2007
5526            0xA9, 0x16, 0x8D, 0x07, 0x20,       // C02D LDA #$16 / STA $2007
5527            // --- nametable + attributes $2000..$23FF = $01
5528            0xA9, 0x20, 0x8D, 0x06, 0x20,       // C032 LDA #$20 / STA $2006
5529            0xA9, 0x00, 0x8D, 0x06, 0x20,       // C037 LDA #$00 / STA $2006  -> $2000
5530            0xA0, 0x04,                         // C03C LDY #$04     (4 x 256)
5531            0xA2, 0x00,                         // C03E LDX #$00
5532            0xA9, 0x01,                         // C040 LDA #$01
5533            0x8D, 0x07, 0x20,                   // C042 STA $2007
5534            0xCA, 0xD0, 0xFA,                   // C045 DEX / BNE $C042
5535            0x88, 0xD0, 0xF3,                   // C048 DEY / BNE $C03E
5536            // --- enable BG: $2000 = $00 (BG pattern table $0000, NT $2000),
5537            //     $2001 = $08 (show BG, but NOT the leftmost 8 px)
5538            0xA9, 0x00, 0x8D, 0x00, 0x20,       // C04B LDA #$00 / STA $2000
5539            0xA9, 0x08, 0x8D, 0x01, 0x20,       // C050 LDA #$08 / STA $2001
5540            0x4C, 0x55, 0xC0,                   // C055 JMP $C055  (spin)
5541        ];
5542        prg[..code.len()].copy_from_slice(code);
5543        let len = 16 * 1024;
5544        prg[len - 4] = 0x00; // reset vector -> $C000
5545        prg[len - 3] = 0xC0;
5546
5547        let mut nes = Nes::from_rom(&bytes).expect("parse");
5548        // Enough frames for the delay loop, then the setup loops, to complete
5549        // and for rendering to be running steadily.
5550        for _ in 0..16 {
5551            let _ = nes.run_frame();
5552        }
5553        assert!(nes.pixel_provenance().is_none(), "off by default");
5554        nes.set_pixel_provenance(true);
5555        let _ = nes.run_frame();
5556
5557        let prov = nes.pixel_provenance().expect("armed");
5558
5559        // Pixel (16, 0) sits in tile column 2 of row 0, i.e. nametable $2002.
5560        let rec = prov.get(16, 0).expect("on-screen");
5561        assert_eq!(
5562            rec.layer,
5563            PixelLayer::Background,
5564            "the all-$FF pattern makes every BG pixel opaque"
5565        );
5566        assert_eq!(
5567            rec.nt_addr, 0x2002,
5568            "the record must name the tile ON SCREEN at x=16; `v` at emit time \
5569             has already advanced two tiles past it, so a value near $2004 here \
5570             would mean the cascade is not tracking the shifters"
5571        );
5572        assert_eq!(rec.at_addr, 0x23C0, "tile (2,0) -> attribute byte 0");
5573        assert_eq!(rec.bg_idx, 1, "low plane $FF, high plane $00 -> index 1");
5574        assert_eq!(rec.palette_addr, 0x3F01, "palette group 0, index 1");
5575        assert_eq!(rec.palette_index, 1);
5576        assert_eq!(rec.color, 0x16, "the red we wrote to $3F01");
5577        assert_eq!(rec.scanline, 0);
5578        assert_eq!(rec.dot, 17, "screen X is dot - 1");
5579        assert_eq!(
5580            rec.sprite_slot,
5581            rustynes_ppu::SPRITE_SLOT_NONE,
5582            "no sprites in this ROM"
5583        );
5584        // Fine-Y is 2, not 0, and that is correct: the ROM sets the scroll only
5585        // via `$2006 = $20, $00`, which loads `v = t = $2000` — and bits 12-14 of
5586        // a VRAM address ARE the fine-Y field, so `$2000` means fine-Y = 2. A ROM
5587        // that wanted row 0 would have written `$2005` afterwards. The record
5588        // reports what the hardware is actually displaying.
5589        assert_eq!(rec.fine_y, 2, "$2006 = $2000 puts fine-Y at 2");
5590        // Tile $01 base $0010, plus fine-Y 2.
5591        assert_eq!(rec.pattern_addr, 0x0012);
5592
5593        // The cascade advances ONE TILE PER GROUP across the scanline — it is
5594        // not a single address held for the whole line, and not skewed by the
5595        // two dummy nametable fetches at dots 337-340.
5596        for (x, want_nt) in [(0usize, 0x2000u16), (8, 0x2001), (24, 0x2003), (40, 0x2005)] {
5597            assert_eq!(
5598                prov.get(x, 0).expect("on-screen").nt_addr,
5599                want_nt,
5600                "tile column at x={x}"
5601            );
5602        }
5603
5604        // Off-screen queries answer `None` rather than clamping to a pixel the
5605        // caller did not ask about.
5606        assert_eq!(prov.get(256, 0), None);
5607        assert_eq!(prov.get(0, 240), None);
5608
5609        nes.set_pixel_provenance(false);
5610        assert!(nes.pixel_provenance().is_none());
5611    }
5612
5613    /// The two halves compose: provenance gives the palette index, attribution
5614    /// gives the instruction that wrote it. This is the end-to-end claim the
5615    /// feature exists to support.
5616    #[cfg(feature = "debug-hooks")]
5617    #[test]
5618    fn provenance_and_attribution_compose_into_a_causal_chain() {
5619        // Minimal ROM: set $3F00 (backdrop) to a known color from a known PC,
5620        // then spin. Rendering stays off, so every pixel is the backdrop and the
5621        // chain is unambiguous.
5622        //   C000: LDA #$3F / STA $2006
5623        //   C005: LDA #$00 / STA $2006      -> v = $3F00
5624        //   C00A: LDA #$21 / STA $2007      <-- the palette write
5625        //   C00F: JMP $C000
5626        const STA_2007_PC: u16 = 0xC00C;
5627        const COLOR: u8 = 0x21;
5628
5629        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
5630        bytes[0..4].copy_from_slice(b"NES\x1A");
5631        bytes[4] = 1;
5632        bytes[5] = 0;
5633        let prg = &mut bytes[16..16 + 16 * 1024];
5634        prg[0..18].copy_from_slice(&[
5635            0xA9, 0x3F, 0x8D, 0x06, 0x20, // LDA #$3F / STA $2006
5636            0xA9, 0x00, 0x8D, 0x06, 0x20, // LDA #$00 / STA $2006
5637            0xA9, COLOR, 0x8D, 0x07, 0x20, // LDA #$21 / STA $2007
5638            0x4C, 0x00, 0xC0, // JMP $C000
5639        ]);
5640        let len = 16 * 1024;
5641        prg[len - 4] = 0x00;
5642        prg[len - 3] = 0xC0;
5643
5644        let mut nes = Nes::from_rom(&bytes).expect("parse");
5645        let _ = nes.run_frame();
5646        nes.set_pixel_provenance(true);
5647        nes.set_write_attribution(true);
5648        for _ in 0..3 {
5649            let _ = nes.run_frame();
5650        }
5651
5652        // Step 1: which palette entry produced this pixel?
5653        let rec = nes
5654            .pixel_provenance()
5655            .and_then(|p| p.get(100, 100))
5656            .expect("armed and on-screen");
5657        assert_eq!(rec.layer, rustynes_ppu::PixelLayer::Backdrop);
5658        assert_eq!(rec.palette_index, 0, "the universal backdrop");
5659        assert_eq!(rec.color, COLOR);
5660
5661        // Step 2: who wrote that palette entry?
5662        let who = nes
5663            .write_attribution()
5664            .and_then(|a| a.palette(rec.palette_index as usize))
5665            .expect("the STA $2007 wrote it");
5666        assert_eq!(
5667            who.pc, STA_2007_PC,
5668            "the chain closes: pixel -> palette entry -> writing instruction"
5669        );
5670        assert_eq!(who.value, COLOR);
5671    }
5672
5673    /// An OAM DMA burst moves 256 bytes but has exactly one cause. The store
5674    /// must say so — attributing all 256 to the `STA $4014` that triggered them
5675    /// rather than inventing a per-byte PC that no instruction ever had.
5676    #[cfg(feature = "debug-hooks")]
5677    #[test]
5678    fn oam_dma_attributes_all_256_bytes_to_the_triggering_store() {
5679        // NROM at $C000:
5680        //   C000: A9 02     LDA #$02
5681        //   C002: 8D 14 40  STA $4014   <-- one instruction, 256 OAM bytes
5682        //   C005: 4C 00 C0  JMP $C000
5683        //
5684        // `$4014` is not subject to the PPU's post-reset write-mask window, so
5685        // this lands on the first pass; the loop just keeps it landing.
5686        const STA_4014_PC: u16 = 0xC002;
5687
5688        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
5689        bytes[0..4].copy_from_slice(b"NES\x1A");
5690        bytes[4] = 1;
5691        bytes[5] = 0;
5692        let prg = &mut bytes[16..16 + 16 * 1024];
5693        prg[0..8].copy_from_slice(&[
5694            0xA9, 0x02, // LDA #$02
5695            0x8D, 0x14, 0x40, // STA $4014
5696            0x4C, 0x00, 0xC0, // JMP $C000
5697        ]);
5698        let len = 16 * 1024;
5699        prg[len - 4] = 0x00;
5700        prg[len - 3] = 0xC0;
5701
5702        let mut nes = Nes::from_rom(&bytes).expect("parse");
5703        // The first `run_frame` after power-on returns on the already-latched
5704        // frame-complete flag, executing almost no instructions — the same
5705        // warm-up the event-viewer tests need. Arm AFTER it, so the assertion
5706        // below is about a frame that actually ran code.
5707        let _ = nes.run_frame();
5708        nes.set_write_attribution(true);
5709        let _ = nes.run_frame();
5710
5711        let attrib = nes.write_attribution().expect("armed");
5712        for idx in 0..=u8::MAX {
5713            let rec = attrib
5714                .oam(idx)
5715                .unwrap_or_else(|| panic!("OAM byte {idx} unattributed after a full DMA burst"));
5716            assert_eq!(
5717                rec.pc, STA_4014_PC,
5718                "OAM byte {idx} must name the STA $4014, not a synthesized PC"
5719            );
5720        }
5721    }
5722
5723    /// A save-state restore must invalidate attribution: the restored bytes were
5724    /// not written by anything this session ran, so the honest answer is "no
5725    /// record", not the PC that wrote that offset on the abandoned timeline.
5726    #[cfg(feature = "debug-hooks")]
5727    #[test]
5728    fn write_attribution_is_invalidated_by_restore() {
5729        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
5730        bytes[0..4].copy_from_slice(b"NES\x1A");
5731        bytes[4] = 1;
5732        bytes[5] = 0;
5733        let prg = &mut bytes[16..16 + 16 * 1024];
5734        // Same looping `$2006`/`$2007` ROM as the test above; see its comment for
5735        // why it loops and why three frames are needed.
5736        prg[0..18].copy_from_slice(&[
5737            0xA9, 0x21, 0x8D, 0x06, 0x20, 0xA9, 0x08, 0x8D, 0x06, 0x20, 0xA9, 0x5A, 0x8D, 0x07,
5738            0x20, 0x4C, 0x00, 0xC0,
5739        ]);
5740        let len = 16 * 1024;
5741        prg[len - 4] = 0x00;
5742        prg[len - 3] = 0xC0;
5743
5744        let mut nes = Nes::from_rom(&bytes).expect("parse");
5745        nes.set_write_attribution(true);
5746        for _ in 0..3 {
5747            let _ = nes.run_frame();
5748        }
5749        let off = 0x0108usize;
5750        assert!(
5751            nes.write_attribution().and_then(|a| a.ciram(off)).is_some(),
5752            "precondition: the write was attributed"
5753        );
5754
5755        let snap = nes.snapshot();
5756        nes.restore(&snap).expect("round-trip");
5757        assert!(
5758            nes.write_attribution().is_some(),
5759            "the store stays armed across a restore"
5760        );
5761        assert_eq!(
5762            nes.write_attribution().and_then(|a| a.ciram(off)),
5763            None,
5764            "but its records are dropped — they describe a timeline that the \
5765             restore replaced"
5766        );
5767    }
5768
5769    #[cfg(feature = "debug-hooks")]
5770    #[test]
5771    fn event_viewer_records_ppu_reads() {
5772        use crate::bus::EventKind;
5773        // A tiny NROM that loops `LDA $2002 ; JMP $C000`, generating a PPU
5774        // STATUS read ($2002) every iteration (v1.5.0 Workstream A2 read tap).
5775        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
5776        bytes[0..4].copy_from_slice(b"NES\x1A");
5777        bytes[4] = 1; // 1x16KB PRG
5778        let prg = &mut bytes[16..16 + 16 * 1024];
5779        // $C000: LDA $2002 ; JMP $C000
5780        prg[0..6].copy_from_slice(&[0xAD, 0x02, 0x20, 0x4C, 0x00, 0xC0]);
5781        let len = 16 * 1024;
5782        prg[len - 4] = 0x00;
5783        prg[len - 3] = 0xC0;
5784
5785        let mut nes = Nes::from_rom(&bytes).expect("parse");
5786        let _ = nes.run_frame();
5787        nes.set_event_logging(true);
5788        let _ = nes.run_frame();
5789        let evs = nes.events();
5790        assert!(
5791            evs.iter()
5792                .any(|e| e.kind == EventKind::PpuRead && e.addr == 0x2002),
5793            "the LDA $2002 loop produces PPU reads"
5794        );
5795        assert!(
5796            evs.iter()
5797                .all(|e| e.kind.is_read() == (e.kind == EventKind::PpuRead)),
5798            "is_read is true only for PpuRead"
5799        );
5800        nes.set_event_logging(false);
5801    }
5802
5803    #[cfg(feature = "debug-hooks")]
5804    #[test]
5805    fn event_breakpoint_fires_on_armed_category_only() {
5806        use crate::EventBpKind;
5807        // The same `LDA #$00 ; STA $2000 ; JMP $C000` loop — it issues a PPU
5808        // write every iteration but never an APU write or interrupt service.
5809        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
5810        bytes[0..4].copy_from_slice(b"NES\x1A");
5811        bytes[4] = 1;
5812        bytes[5] = 0;
5813        let prg = &mut bytes[16..16 + 16 * 1024];
5814        prg[0..8].copy_from_slice(&[0xA9, 0x00, 0x8D, 0x00, 0x20, 0x4C, 0x00, 0xC0]);
5815        let len = 16 * 1024;
5816        prg[len - 4] = 0x00;
5817        prg[len - 3] = 0xC0;
5818
5819        let mut nes = Nes::from_rom(&bytes).expect("parse");
5820        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
5821
5822        // Default: nothing armed, no hits.
5823        assert_eq!(nes.event_breakpoints(), 0, "disarmed by default");
5824        let _ = nes.run_frame();
5825        assert_eq!(nes.take_event_break_hit(), None, "no hit while disarmed");
5826
5827        // Arm an UNRELATED category (APU write): the $2000 loop never trips it.
5828        nes.set_event_breakpoints(EventBpKind::ApuWrite.bit());
5829        let _ = nes.run_frame();
5830        assert_eq!(
5831            nes.take_event_break_hit(),
5832            None,
5833            "wrong category does not fire"
5834        );
5835
5836        // Arm PPU write: the very next frame must latch a hit with sane context.
5837        nes.set_event_breakpoints(EventBpKind::PpuWrite.bit());
5838        let _ = nes.run_frame();
5839        let hit = nes.take_event_break_hit().expect("PPU write fires");
5840        assert_eq!(hit.kind, EventBpKind::PpuWrite);
5841        assert_eq!(hit.addr, 0x2000, "the STA $2000 target");
5842        assert!(hit.dot <= 340, "dot in range");
5843        assert!(
5844            hit.scanline >= -1 && hit.scanline <= 260,
5845            "scanline in range"
5846        );
5847        // Cleared on read + only one (first) hit recorded per frame.
5848        assert_eq!(nes.take_event_break_hit(), None, "cleared on read");
5849
5850        // Disarming all stops it firing again.
5851        nes.set_event_breakpoints(0);
5852        let _ = nes.run_frame();
5853        assert_eq!(nes.take_event_break_hit(), None, "disarmed: silent");
5854    }
5855
5856    #[cfg(feature = "debug-hooks")]
5857    #[test]
5858    fn event_bp_kind_mask_and_labels_are_distinct() {
5859        use crate::EventBpKind;
5860        let all = EventBpKind::all();
5861        // Every category has a distinct bit and a non-empty label.
5862        let mut seen = 0u16;
5863        for k in all {
5864            assert_eq!(seen & k.bit(), 0, "{} bit collides", k.label());
5865            seen |= k.bit();
5866            assert_ne!(k.label(), "");
5867        }
5868        assert_eq!(seen.count_ones() as usize, all.len(), "11 distinct bits");
5869    }
5870
5871    #[test]
5872    fn debug_snapshots_are_read_only() {
5873        // T-53-002+ -- inspection must not advance emulator state.
5874        let rom = synth_nrom(16, 8);
5875        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
5876        for _ in 0..2 {
5877            nes.run_frame();
5878        }
5879        let cycle_before = nes.cycle();
5880        let _cpu = nes.cpu_snapshot();
5881        let _ppu = nes.ppu_snapshot();
5882        let _apu = nes.apu_snapshot();
5883        let _oam = nes.oam();
5884        let _pal = nes.palette_ram();
5885        let _mapper = nes.mapper_info();
5886        // cpu_bus_peek and pattern_table_rgba take &mut so we exercise them too.
5887        let _byte = nes.cpu_bus_peek(0xC000);
5888        let _byte = nes.ppu_bus_peek(0x2000);
5889        let pt = nes.pattern_table_rgba(0);
5890        assert_eq!(pt.len(), 128 * 128 * 4, "pattern table RGBA size");
5891        let nt = nes.nametable_rgba(0);
5892        assert_eq!(nt.len(), 256 * 240 * 4, "nametable RGBA size");
5893        assert_eq!(nes.cycle(), cycle_before, "inspection MUST NOT tick CPU");
5894    }
5895
5896    #[test]
5897    fn disassembler_round_trips_against_cpu_bus() {
5898        // Walk a small synthesized program through the disassembler.
5899        let rom = synth_nrom(16, 8);
5900        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
5901        let pc = nes.cpu().pc;
5902        // Take a fixed-size byte window via the peek API first; disasm
5903        // wants a `Fn`, and our peek is `FnMut`.
5904        let mut buf = [0u8; 16];
5905        for (i, b) in buf.iter_mut().enumerate() {
5906            *b = nes.cpu_bus_peek(pc.wrapping_add(u16::try_from(i).unwrap_or(0)));
5907        }
5908        let lines = rustynes_cpu::disassemble_at(
5909            |a| {
5910                let off = a.wrapping_sub(pc) as usize;
5911                buf.get(off).copied().unwrap_or(0)
5912            },
5913            pc,
5914            4,
5915        );
5916        assert_eq!(lines.len(), 4);
5917        // First instruction is JMP $C000 (0x4C 0x00 0xC0).
5918        assert_eq!(lines[0].addr, pc);
5919        assert_eq!(lines[0].mnemonic, "JMP");
5920    }
5921
5922    #[test]
5923    fn rom_sha256_is_deterministic() {
5924        let rom = synth_nrom(16, 8);
5925        let nes_a = Nes::from_rom(&rom).unwrap();
5926        let nes_b = Nes::from_rom(&rom).unwrap();
5927        assert_eq!(nes_a.rom_sha256(), nes_b.rom_sha256());
5928        // Different ROM -> different hash.
5929        let mut other = synth_nrom(16, 8);
5930        other[0x10] = 0x99;
5931        let nes_c = Nes::from_rom(&other).unwrap();
5932        assert_ne!(nes_a.rom_sha256(), nes_c.rom_sha256());
5933    }
5934
5935    /// v2.9.8: the persistent identity ignores the 16-byte header, so a
5936    /// load-time header correction cannot rename a game's saves; the
5937    /// whole-image hash (the Vs. database's fallback key) still sees the header.
5938    #[test]
5939    fn rom_identity_ignores_the_header_and_image_hash_does_not() {
5940        let rom = synth_nrom(16, 8);
5941        let mut reheaded = rom.clone();
5942        // Bytes 10-15 are unused padding in an iNES 1.0 header, so this is a
5943        // pure header edit on the same board.
5944        reheaded[12] = 0x01;
5945        let a = Nes::from_rom(&rom).unwrap();
5946        let b = Nes::from_rom(&reheaded).unwrap();
5947        assert_eq!(
5948            a.rom_sha256(),
5949            b.rom_sha256(),
5950            "header edit renamed the ROM"
5951        );
5952        assert_eq!(a.rom_hash_tag(), b.rom_hash_tag());
5953        assert_ne!(a.image_sha256(), b.image_sha256());
5954        // Exactly the body: the identity is SHA-256 of bytes[16..].
5955        assert_eq!(*a.rom_sha256(), sha256_of(&rom[16..]));
5956        assert_eq!(*a.image_sha256(), sha256_of(&rom));
5957    }
5958
5959    /// v2.9.9 (NF-21) — `rom_identity_of` is the identity a console built
5960    /// from the same image reports, for each image kind.
5961    #[test]
5962    fn rom_identity_of_matches_the_built_console() {
5963        let rom = synth_nrom(16, 8);
5964        assert_eq!(
5965            Nes::rom_identity_of(&rom),
5966            *Nes::from_rom(&rom).unwrap().rom_sha256()
5967        );
5968        let disk = synth_fds_disk();
5969        let fds = Nes::from_disk(&disk, &synth_fds_bios()).unwrap();
5970        assert_eq!(Nes::rom_identity_of(&disk), *fds.rom_sha256());
5971        let nsf = synth_tone_nsf();
5972        let tune = Nes::from_nsf(&nsf).unwrap();
5973        assert_eq!(Nes::rom_identity_of(&nsf), *tune.rom_sha256());
5974        // And it is not the whole-file hash for a cartridge.
5975        assert_ne!(Nes::rom_identity_of(&rom), sha256_of(&rom));
5976    }
5977
5978    /// A synthetic 8 KiB FDS BIOS: `JMP $E000` at the reset vector and an
5979    /// `RTI` for NMI / IRQ. Enough for the disk constructors to boot.
5980    fn synth_fds_bios() -> Vec<u8> {
5981        let mut bios = vec![0u8; 8 * 1024];
5982        bios[..3].copy_from_slice(&[0x4C, 0x00, 0xE0]); // $E000: JMP $E000
5983        bios[0x80] = 0x40; // $E080: RTI
5984        bios[0x1FFA..].copy_from_slice(&[0x80, 0xE0, 0x00, 0xE0, 0x80, 0xE0]);
5985        bios
5986    }
5987
5988    /// A one-sided fwNES-headed disk: the disk-info block signature is all the
5989    /// container parser needs.
5990    fn synth_fds_disk() -> Vec<u8> {
5991        let mut disk = vec![0u8; 16 + 65_500];
5992        disk[..4].copy_from_slice(b"FDS\x1A");
5993        disk[4] = 1;
5994        disk[16] = 0x01;
5995        disk[17..31].copy_from_slice(b"*NINTENDO-HVC*");
5996        disk
5997    }
5998
5999    /// v2.9.9 (NF-17) — a console booted from a saved copy of an FDS disk
6000    /// reports the pristine disk's identity once the host supplies it, so
6001    /// everything keyed on `rom_sha256` (slots and the `.rns` tag, cheats,
6002    /// movies, netplay, per-game keys, RA progress) survives the game's own
6003    /// disk saves. `image_sha256` keeps describing the bytes in hand.
6004    #[test]
6005    fn a_saved_disk_boot_reports_the_pristine_identity() {
6006        let bios = synth_fds_bios();
6007        let pristine = synth_fds_disk();
6008        let pristine_sha = *Nes::from_disk(&pristine, &bios).unwrap().rom_sha256();
6009        // The game wrote to its disk; the host boots the written image.
6010        let mut saved = pristine;
6011        saved[16 + 0x40] = 0x5A;
6012        let mut booted = Nes::from_disk(&saved, &bios).unwrap();
6013        let saved_sha = *booted.rom_sha256();
6014        assert_ne!(saved_sha, pristine_sha, "fixture: the images differ");
6015
6016        booted.set_rom_identity(pristine_sha);
6017        assert_eq!(
6018            *booted.rom_sha256(),
6019            pristine_sha,
6020            "a disk save moved the game's identity"
6021        );
6022        assert_eq!(booted.rom_hash_tag()[..], pristine_sha[..ROM_HASH_TAG_LEN]);
6023        assert_eq!(
6024            *booted.image_sha256(),
6025            saved_sha,
6026            "the image hash must stay the bytes actually loaded"
6027        );
6028        // The identity is not state: a cold boot keeps it.
6029        booted.power_cycle();
6030        assert_eq!(*booted.rom_sha256(), pristine_sha);
6031    }
6032
6033    #[test]
6034    fn thumbnail_has_expected_dimensions() {
6035        let rom = synth_nrom(16, 8);
6036        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
6037        nes.run_frame();
6038        let thumb = nes.thumbnail();
6039        assert_eq!(thumb.len(), save_state::THUMBNAIL_LEN);
6040        assert_eq!(
6041            save_state::THUMBNAIL_LEN,
6042            save_state::THUMBNAIL_WIDTH * save_state::THUMBNAIL_HEIGHT * 4
6043        );
6044    }
6045
6046    #[test]
6047    fn snapshot_includes_thumbnail_section_extractable() {
6048        let rom = synth_nrom(16, 8);
6049        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
6050        for _ in 0..2 {
6051            nes.run_frame();
6052        }
6053        let blob = nes.snapshot();
6054        let extracted = Nes::extract_thumbnail(&blob).expect("blob is valid");
6055        let thumb = extracted.expect("snapshot must include THM section");
6056        assert_eq!(thumb.len(), save_state::THUMBNAIL_LEN);
6057        // Round-trip: thumbnail bytes must match the live framebuffer
6058        // downsample taken at the same cycle.
6059        assert_eq!(thumb, nes.thumbnail());
6060    }
6061
6062    #[test]
6063    fn snapshot_round_trip_still_works_with_thumbnail() {
6064        // ADR-0003 invariant: adding THM must not perturb deterministic
6065        // restore. Re-runs the snapshot_round_trip test with the new
6066        // thumbnail section present in the blob.
6067        let rom = synth_nrom(16, 8);
6068        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
6069        for _ in 0..4 {
6070            nes.run_frame();
6071        }
6072        let cycle = nes.cycle();
6073        let fb_hash_before = fnv_hash(nes.framebuffer());
6074        let blob = nes.snapshot();
6075        for _ in 0..4 {
6076            nes.run_frame();
6077        }
6078        assert_ne!(nes.cycle(), cycle);
6079        nes.restore(&blob)
6080            .expect("restore must succeed with THM present");
6081        assert_eq!(nes.cycle(), cycle);
6082        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash_before);
6083    }
6084
6085    #[test]
6086    fn restore_accepts_v0_9_0_blob_without_thumbnail() {
6087        // ADR-0003 invariant: older slot files without a THM section must
6088        // still restore. Simulate a v0.9.0 blob by stripping the THM
6089        // section out of a freshly-emitted snapshot.
6090        let rom = synth_nrom(16, 8);
6091        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
6092        for _ in 0..3 {
6093            nes.run_frame();
6094        }
6095        let cycle = nes.cycle();
6096        let fb_hash = fnv_hash(nes.framebuffer());
6097        let with_thumb = nes.snapshot();
6098
6099        // Reconstruct a blob without the THM section.
6100        let (_h, body_off) = save_state::parse_header(&with_thumb).unwrap();
6101        let mut without_thumb = with_thumb[..body_off].to_vec();
6102        for s in save_state::SectionIter::new(&with_thumb[body_off..]) {
6103            let s = s.unwrap();
6104            if s.tag == save_state::tag::THM {
6105                continue;
6106            }
6107            save_state::write_section(&mut without_thumb, s.tag, s.version, s.body);
6108        }
6109        assert!(without_thumb.len() < with_thumb.len());
6110        // Extract on the v0.9.0-shaped blob returns None for the thumbnail.
6111        let extracted = Nes::extract_thumbnail(&without_thumb).unwrap();
6112        assert!(extracted.is_none(), "v0.9.0 blob has no THM section");
6113
6114        // Drift then restore from the v0.9.0-shaped blob.
6115        for _ in 0..2 {
6116            nes.run_frame();
6117        }
6118        nes.restore(&without_thumb)
6119            .expect("v0.9.0 blob must restore");
6120        assert_eq!(nes.cycle(), cycle);
6121        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash);
6122    }
6123
6124    #[test]
6125    fn restore_accepts_a_snapshot_followed_by_zero_padding() {
6126        // v2.8.0 (libretro audit §2.2, with §2.1). The libretro core reports a
6127        // `retro_serialize_size` with headroom for expansion devices, so the
6128        // frontend hands `retro_unserialize` this core's own state followed by
6129        // zeros. Both restore paths walk the sections (`SystemBus::restore`
6130        // for BUS/PPU/APU/MAP, then `apply_snapshot` for CPU), so both must end
6131        // at the padding rather than report it as a damaged section.
6132        let rom = synth_nrom(16, 8);
6133        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
6134        for _ in 0..3 {
6135            nes.run_frame();
6136        }
6137        let cycle = nes.cycle();
6138        let fb_hash = fnv_hash(nes.framebuffer());
6139        let mut blob = Vec::new();
6140        nes.snapshot_core_into(&mut blob);
6141        for pad in [1, 8, 9, 10, 26, 160] {
6142            let mut padded = blob.clone();
6143            padded.resize(blob.len() + pad, 0);
6144            nes.run_frame();
6145            nes.restore_quiet(&padded)
6146                .unwrap_or_else(|e| panic!("{pad} bytes of zero padding: {e:?}"));
6147            assert_eq!(nes.cycle(), cycle, "{pad}: cycle restored");
6148            assert_eq!(
6149                fnv_hash(nes.framebuffer()),
6150                fb_hash,
6151                "{pad}: framebuffer restored"
6152            );
6153        }
6154    }
6155
6156    #[test]
6157    fn restore_rejects_every_truncation_even_with_the_zero_tail_rule() {
6158        // v2.8.0, review on #556 (agy). `SectionIter` now ends at an all-zero
6159        // tail, so the question is whether a TRUNCATED state (a short file:
6160        // a frontend passes `retro_unserialize` a buffer of the file's size
6161        // and does not pad it) could now pass as a complete one that happens
6162        // to end in zeros. Every proper prefix of a real snapshot is tried:
6163        // a cut inside a section leaves a tail that starts with a non-zero
6164        // tag and a length the remaining bytes cannot satisfy, and a cut on a
6165        // section boundary drops a required section. Every one is an error.
6166        let rom = synth_nrom(16, 8);
6167        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
6168        for _ in 0..3 {
6169            nes.run_frame();
6170        }
6171        let mut blob = Vec::new();
6172        nes.snapshot_core_into(&mut blob);
6173        let accepted: Vec<usize> = (0..blob.len())
6174            .filter(|&k| nes.restore_quiet(&blob[..k]).is_ok())
6175            .collect();
6176        assert!(
6177            accepted.is_empty(),
6178            "{} of {} truncations restored, first at {:?}",
6179            accepted.len(),
6180            blob.len(),
6181            accepted.first()
6182        );
6183        // The full state still restores after all those rejections.
6184        nes.restore_quiet(&blob)
6185            .expect("the untruncated state restores");
6186    }
6187
6188    /// Build a minimal NSF (3 songs) whose `init` enables all APU channels and
6189    /// programs a steady pulse-1 tone, and whose `play` is a bare `RTS`. Loaded
6190    /// at $8000; init=$8000, play=$800C.
6191    fn synth_tone_nsf() -> Vec<u8> {
6192        let mut f = vec![0u8; 0x80];
6193        f[0..5].copy_from_slice(b"NESM\x1A");
6194        f[0x05] = 1; // version
6195        f[0x06] = 3; // total songs
6196        f[0x07] = 1; // starting song
6197        f[0x08] = 0x00;
6198        f[0x09] = 0x80; // load $8000
6199        f[0x0A] = 0x00;
6200        f[0x0B] = 0x80; // init $8000
6201        f[0x0C] = 0x0C;
6202        f[0x0D] = 0x80; // play $800C
6203        let program: &[u8] = &[
6204            // init ($8000): enable channels + a constant-volume pulse-1 tone.
6205            0xA9, 0x0F, 0x8D, 0x15, 0x40, // LDA #$0F; STA $4015
6206            0xA9, 0xBF, 0x8D, 0x00, 0x40, // LDA #$BF; STA $4000 (duty/const vol)
6207            0x60, // RTS
6208            0xA0, // padding so play lands at $800C
6209            // play ($800C):
6210            0x60, // RTS
6211        ];
6212        f.extend_from_slice(program);
6213        f
6214    }
6215
6216    #[test]
6217    fn nsf_constructs_runs_and_selects_tracks() {
6218        let mut nes = Nes::from_nsf(&synth_tone_nsf()).expect("valid nsf builds");
6219        assert_eq!(nes.nsf_song_count(), 3);
6220        assert_eq!(nes.nsf_current_song(), 0);
6221
6222        // Run several frames: the driver's reset vector runs `init` (enabling
6223        // the APU + pulse-1), then vblank NMI calls `play` each frame. Audio
6224        // must be produced and the run must not panic.
6225        let mut produced = 0usize;
6226        for _ in 0..8 {
6227            nes.run_frame();
6228            produced += nes.drain_audio().len();
6229        }
6230        assert!(produced > 0, "NSF playback must produce audio samples");
6231
6232        // Track select clamps + restarts on the new song.
6233        nes.nsf_set_song(2);
6234        assert_eq!(nes.nsf_current_song(), 2);
6235        nes.run_frame();
6236        nes.nsf_set_song(99);
6237        assert_eq!(nes.nsf_current_song(), 2, "clamped to last song");
6238    }
6239
6240    /// A minimal non-60-Hz NSF whose `play` routine increments zero-page `$00`,
6241    /// so a test can count how many times the cycle-timer IRQ drove `play`.
6242    fn synth_counting_nsf_50hz() -> Vec<u8> {
6243        let mut f = vec![0u8; 0x80];
6244        f[0..5].copy_from_slice(b"NESM\x1A");
6245        f[0x05] = 1; // version
6246        f[0x06] = 1; // 1 song
6247        f[0x07] = 1; // starting song
6248        f[0x08] = 0x00;
6249        f[0x09] = 0x80; // load $8000
6250        f[0x0A] = 0x00;
6251        f[0x0B] = 0x80; // init $8000
6252        f[0x0C] = 0x06;
6253        f[0x0D] = 0x80; // play $8006
6254        // NTSC play-speed divider $6E-$6F = 20000 µs (~50 Hz) — a non-standard
6255        // rate that selects the cycle-timer IRQ driver instead of vblank-NMI.
6256        f[0x6E] = 0x20;
6257        f[0x6F] = 0x4E; // 0x4E20 = 20000
6258        let program: &[u8] = &[
6259            // init ($8000): enable APU channels, RTS. (6 bytes)
6260            0xA9, 0x0F, 0x8D, 0x15, 0x40, 0x60, // play ($8006): INC $00; RTS
6261            0xE6, 0x00, 0x60,
6262        ];
6263        f.extend_from_slice(program);
6264        f
6265    }
6266
6267    #[test]
6268    fn nsf_nonstandard_rate_drives_play_via_timer_irq() {
6269        let mut nes = Nes::from_nsf(&synth_counting_nsf_50hz()).expect("valid nsf");
6270        // 12 NTSC frames ≈ 0.2 s. A ~50 Hz play-timer IRQ must fire `play`
6271        // (INC $00) several times — proving the cycle-timer IRQ path works
6272        // end-to-end through `run_frame` — but FEWER than the 12 frames, since
6273        // 50 Hz is slower than the 60 Hz once-per-vblank rate.
6274        for _ in 0..12 {
6275            nes.run_frame();
6276        }
6277        let calls = nes.cpu_bus_peek(0x0000);
6278        assert!(
6279            calls > 0,
6280            "timer IRQ must drive `play` at the non-standard rate"
6281        );
6282        assert!(
6283            calls < 12,
6284            "50 Hz must call `play` fewer times than 60 Hz frames (got {calls})"
6285        );
6286    }
6287
6288    /// v2.3.7: `$4014` and `$4016` must actually be attributed.
6289    ///
6290    /// Both sit inside the `$4000-$4017` window the audio-provenance table
6291    /// reserves slots for, and both are handled entirely on the bus — `Bus::write`
6292    /// routes only `$4000-$4013 | $4015 | $4017` to `Apu::write_register`, which
6293    /// is where attribution was recorded. So the two reserved slots could never
6294    /// be filled, while `docs/audio-provenance.md` and the `REG_COUNT` doc
6295    /// comment both stated they were "tracked anyway".
6296    ///
6297    /// Caught by the Antigravity reviewer on PR #404. This test fails without
6298    /// `Apu::record_bus_handled_register_write` being called from both bus arms:
6299    /// remove either call and the corresponding `get()` returns `None`.
6300    #[cfg(feature = "debug-hooks")]
6301    #[test]
6302    fn bus_handled_apu_window_writes_are_attributed() {
6303        // `Bus::write` is the ordinary CPU write path both addresses travel.
6304        use rustynes_cpu::Bus as _;
6305
6306        let mut nes = Nes::from_rom(&synth_nrom(16, 8)).expect("nrom builds");
6307        nes.set_audio_provenance(true);
6308        assert!(nes.audio_provenance_armed(), "premise: armed");
6309
6310        // Pin a known attribution context, then write both bus-handled
6311        // addresses through the ordinary CPU write path.
6312        nes.bus.apu.set_attrib_context(0xC123, 4_242);
6313        nes.bus.write(0x4014, 0x02); // OAM DMA page
6314        nes.bus.write(0x4016, 0x01); // controller strobe
6315
6316        let attrib = nes
6317            .bus
6318            .apu
6319            .register_attribution()
6320            .expect("armed, so the table exists");
6321
6322        let dma = attrib
6323            .get(0x4014)
6324            .expect("$4014 must be attributed — it is inside the reserved window");
6325        assert_eq!(dma.pc, 0xC123, "$4014 attributed to the wrong instruction");
6326        assert_eq!(dma.value, 0x02, "$4014 recorded the wrong value");
6327
6328        let strobe = attrib
6329            .get(0x4016)
6330            .expect("$4016 must be attributed — it is inside the reserved window");
6331        assert_eq!(
6332            strobe.pc, 0xC123,
6333            "$4016 attributed to the wrong instruction"
6334        );
6335        assert_eq!(strobe.value, 0x01, "$4016 recorded the wrong value");
6336
6337        // A genuine APU register still works — the new path is additive, not a
6338        // replacement for the one inside `write_register`.
6339        nes.bus.write(0x4015, 0x0F);
6340        assert_eq!(
6341            attrib_value(&nes, 0x4015),
6342            Some(0x0F),
6343            "the normal write_register attribution path must be unaffected"
6344        );
6345    }
6346
6347    /// Small helper so the assertion above reads as one line.
6348    #[cfg(feature = "debug-hooks")]
6349    fn attrib_value(nes: &Nes, addr: u16) -> Option<u8> {
6350        nes.bus
6351            .apu
6352            .register_attribution()
6353            .and_then(|a| a.get(addr))
6354            .map(|w| w.value)
6355    }
6356
6357    #[test]
6358    fn nsf_song_apis_are_inert_on_a_cartridge() {
6359        let mut nes = Nes::from_rom(&synth_nrom(16, 8)).expect("nrom builds");
6360        assert_eq!(nes.nsf_song_count(), 0);
6361        assert_eq!(nes.nsf_current_song(), 0);
6362        nes.nsf_set_song(1); // no-op, must not panic or reset spuriously
6363        assert_eq!(nes.nsf_current_song(), 0);
6364    }
6365
6366    /// v2.3.6: the beam-relative Zapper model is ON by default.
6367    ///
6368    /// It shipped OFF in v2.2.3-v2.3.5 on the reasoning that no light-gun test
6369    /// ROM could adjudicate it and the supported titles were satisfied either
6370    /// way. The second half was false: under the frame-granular model *Duck
6371    /// Hunt* receives its "dark frame then bright frame" probe inverted and can
6372    /// never register a hit. See `SystemBus::set_zapper_temporal_light`.
6373    #[test]
6374    fn zapper_temporal_light_is_on_by_default() {
6375        let mut nes = Nes::from_rom(&synth_nrom(16, 8)).expect("nrom builds");
6376        assert!(
6377            nes.zapper_temporal_light(),
6378            "the beam-relative model must default ON from v2.3.6"
6379        );
6380        nes.set_zapper(1, 100, 120, false);
6381        // Toggling it off and back on must restore the default exactly.
6382        nes.set_zapper_temporal_light(false);
6383        assert!(!nes.zapper_temporal_light());
6384        nes.set_zapper_temporal_light(true);
6385        assert!(nes.zapper_temporal_light());
6386    }
6387
6388    /// The timeline counter is session-local: a save state neither carries it nor
6389    /// restores it, and loading one ADVANCES the live counter instead.
6390    ///
6391    /// Documented on the field, and untestable by `snapshot_schema_audit` for the
6392    /// very reason that makes it true -- the counter lives outside the snapshot,
6393    /// so that audit cannot see it. Pinned here instead, because the two ways
6394    /// serializing it would be wrong are both silent: loading the same slot twice
6395    /// would restore the same generation twice and a consumer would miss the
6396    /// second load, and a value from another session means nothing in this one.
6397    #[test]
6398    fn the_timeline_counter_is_session_local_and_advances_on_restore() {
6399        let rom = synth_nrom(16, 8);
6400        let mut nes = Nes::from_rom(&rom).expect("parse");
6401        nes.run_frame();
6402        let state = nes.snapshot();
6403        let before = nes.timeline_generation();
6404
6405        nes.restore(&state).expect("restore");
6406        let after_first = nes.timeline_generation();
6407        assert!(
6408            after_first > before,
6409            "a restore must advance the timeline counter: {before} -> {after_first}"
6410        );
6411
6412        // The SECOND load of the SAME slot must advance it again. This is the
6413        // assertion that would fail if the counter were serialized -- the restored
6414        // value would be identical both times and a consumer comparing against its
6415        // last-seen value would never notice the second load.
6416        nes.restore(&state).expect("restore again");
6417        assert!(
6418            nes.timeline_generation() > after_first,
6419            "reloading the same slot must still register as a new timeline"
6420        );
6421
6422        // And a snapshot taken now must not encode it: a fresh `Nes` restored from
6423        // this state starts its own count rather than adopting ours.
6424        let mut fresh = Nes::from_rom(&rom).expect("parse");
6425        assert_eq!(
6426            fresh.timeline_generation(),
6427            0,
6428            "a fresh Nes must start at zero"
6429        );
6430        fresh.restore(&nes.snapshot()).expect("restore into fresh");
6431        assert_eq!(
6432            fresh.timeline_generation(),
6433            1,
6434            "the fresh instance must count its own restores, not inherit a stored value"
6435        );
6436    }
6437}