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::LockstepBus;
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/// v1.1.0 beta.2 (Workstream C, T-110-C2) — one cycle-trace record.
84///
85/// The CPU register file + cycle count captured just before an instruction
86/// executes. Recorded by [`Nes::run_frame`] while tracing is enabled (the
87/// `debug-hooks` feature). The frontend disassembles the instruction at `pc`.
88#[cfg(feature = "debug-hooks")]
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct TraceRec {
91    /// Program counter at instruction fetch.
92    pub pc: u16,
93    /// Accumulator.
94    pub a: u8,
95    /// X index.
96    pub x: u8,
97    /// Y index.
98    pub y: u8,
99    /// Stack pointer.
100    pub s: u8,
101    /// Processor status bits.
102    pub p: u8,
103    /// CPU cycle count at fetch.
104    pub cycle: u64,
105}
106
107/// Top-level NES emulator handle.
108///
109/// Owns the CPU, PPU, mapper, RAM, and controller stub. Construct via
110/// [`Nes::from_rom`]; drive forward via [`Nes::run_frame`] or
111/// [`Nes::step_instruction`]. The framebuffer can be sampled at any time via
112/// [`Nes::framebuffer`].
113// Several independent debug-hooks toggles (breakpoints / trace / exec log) push
114// the bool count over clippy's threshold; they are genuinely independent flags.
115#[allow(clippy::struct_excessive_bools)]
116pub struct Nes {
117    cpu: Cpu,
118    bus: LockstepBus,
119    /// SHA-256 of the original ROM bytes the emulator was constructed from.
120    rom_sha256: [u8; 32],
121    /// Optional rewind ring buffer. Disabled by default — frontend opts in
122    /// via [`Nes::enable_rewind`].
123    rewind: Option<RewindRing>,
124    /// v2.8.0 Phase 3 — when `false`, [`Nes::run_frame`] skips the rewind
125    /// capture even with the ring armed. Run-ahead sets this for its
126    /// hidden + visible frames so only the persistent timeline's frames
127    /// land in the ring. Default `true` (byte-identical legacy behavior).
128    rewind_capture_enabled: bool,
129    /// v2.8.0 Phase 3 — reused scratch for the per-frame rewind capture
130    /// (kills the ~320 KiB snapshot allocation per frame).
131    rewind_snap_buf: Vec<u8>,
132    /// Optional per-CPU-instruction boot trace (Session-12 observability).
133    /// Gated on the `cpu-boot-trace` cargo feature so the default build
134    /// pays no memory or codegen cost. See
135    /// `crates/rustynes-core/src/cpu_boot_trace.rs`.
136    #[cfg(feature = "cpu-boot-trace")]
137    cpu_boot_trace: Option<crate::cpu_boot_trace::CpuBootTrace>,
138    /// v1.1.0 beta.2 (Workstream C) — exec/PC breakpoints checked in
139    /// [`Nes::run_frame`]. Gated on `debug-hooks` so the default build's hot
140    /// path is untouched. Output-only: a hit stops the frame early and records
141    /// the PC; it never mutates emulation, so determinism holds.
142    #[cfg(feature = "debug-hooks")]
143    breakpoints: Vec<u16>,
144    /// Whether breakpoints are armed (lets the UI keep its list but pause
145    /// checking). Default `true`.
146    #[cfg(feature = "debug-hooks")]
147    breakpoints_enabled: bool,
148    /// The PC that last hit a breakpoint, taken by the frontend to pause.
149    #[cfg(feature = "debug-hooks")]
150    break_hit: Option<u16>,
151    /// The PC to skip the breakpoint check on for the next step, so a
152    /// "continue" resumes *past* the instruction it stopped on instead of
153    /// re-breaking. Unlike a blind "skip the first iteration", this only skips
154    /// the exact resumed PC — so a breakpoint sitting at the frame's start PC
155    /// (after a reset / save-state load / manual PC change) still fires.
156    #[cfg(feature = "debug-hooks")]
157    skip_breakpoint_at: Option<u16>,
158    /// v1.1.0 beta.2 (T-110-C2) — cycle-trace ring buffer (most-recent
159    /// [`Self::TRACE_CAP`] instructions). Recorded in `run_frame` while
160    /// `trace_enabled`. Output-only.
161    #[cfg(feature = "debug-hooks")]
162    trace: alloc::collections::VecDeque<TraceRec>,
163    /// Whether the cycle-trace logger is recording. Default `false`.
164    #[cfg(feature = "debug-hooks")]
165    trace_enabled: bool,
166    /// v1.1.0 beta.3 (T-110-E2) — per-frame executed-PC log for the Lua
167    /// `onExec` callback. Distinct from [`Self::trace`] (a 50k rolling buffer
168    /// shared with the Trace Logger panel): this is **cleared every frame**, so
169    /// `onExec` replays only this frame's PCs — no stale / duplicate dispatch.
170    /// Output-only; recorded only while `exec_logging`.
171    #[cfg(feature = "debug-hooks")]
172    exec_log: Vec<u16>,
173    /// Whether the per-frame exec-PC log is recording. Default `false`.
174    #[cfg(feature = "debug-hooks")]
175    exec_logging: bool,
176    /// v2.4.0 item B — a monotonic marker that changes whenever this `Nes`
177    /// jumps to a different point on its timeline.
178    ///
179    /// **Session-local, and deliberately NOT serialized.** The counter's only job
180    /// is to be *different* after a discontinuity. Serializing it would put an
181    /// OLD value back on restore, so loading a state saved earlier in the same
182    /// session could hand a consumer a generation it has already seen — and the
183    /// consumer would conclude nothing jumped at the exact moment something did.
184    /// A session-local monotonic counter cannot do that: it only ever increases,
185    /// so any restore produces a value no consumer has seen.
186    ///
187    /// It exists because the alternative — each consumer remembering the last
188    /// `cycle()` it saw and noticing a non-monotonic step — cannot see a restore
189    /// to a LATER state. The counter can, and needs no cooperation from any call
190    /// site, which matters because two of the four timeline jumps (wasm
191    /// load-state, and rewind) are not reachable from a patchable frontend call
192    /// site at all.
193    ///
194    /// # It is NOT part of the save state, deliberately
195    ///
196    /// The counter is session-local: it is not written by `snapshot`, not read by
197    /// `restore`, and a loaded state does not carry its own value across. What a
198    /// restore does instead is **increment the live counter**, which is the
199    /// correct reading of the event — "the timeline you were on has been replaced"
200    /// — and is true regardless of which state was loaded.
201    ///
202    /// Serializing it would be actively wrong in two ways. Loading the same slot
203    /// twice would restore the same generation twice, so a consumer comparing
204    /// against its last-seen value would miss the second load entirely. And a
205    /// value from another session says nothing about this one: the counter is only
206    /// ever meaningful as a comparison against the previous value *this process*
207    /// observed, which is why `timeline_generation()` documents that comparing it
208    /// across two `Nes` instances is meaningless.
209    ///
210    /// A consequence worth stating: because it lives outside the snapshot, the
211    /// `snapshot_schema_audit` test cannot see it, so nothing mechanical will
212    /// notice if this reasoning is ever invalidated.
213    timeline_generation: u64,
214}
215
216impl Nes {
217    /// The current timeline generation — see the field's documentation.
218    ///
219    /// A consumer holds the last value it saw and clears itself when this
220    /// differs. It is meaningless to compare across two different `Nes`
221    /// instances: a fresh one starts at zero, which is why ROM changes are
222    /// handled by their own hook (`DebuggerOverlay::clear_rom_bound_analysis`)
223    /// rather than by this counter.
224    #[must_use]
225    pub const fn timeline_generation(&self) -> u64 {
226        self.timeline_generation
227    }
228
229    /// Returns a reference to the internal WRAM.
230    pub fn wram(&self) -> &[u8] {
231        self.bus.ram.as_ref()
232    }
233
234    /// Returns a mutable reference to the internal WRAM.
235    pub fn wram_mut(&mut self) -> &mut [u8] {
236        self.bus.ram.as_mut()
237    }
238
239    /// Returns a reference to the cartridge SRAM (if any).
240    pub fn sram(&self) -> &[u8] {
241        self.bus.mapper.sram()
242    }
243
244    /// Returns a mutable reference to the cartridge SRAM (if any).
245    pub fn sram_mut(&mut self) -> &mut [u8] {
246        self.bus.mapper.sram_mut()
247    }
248
249    /// Returns a reference to the internal VRAM (nametables).
250    pub fn vram(&self) -> &[u8] {
251        self.bus.ppu.vram_ref()
252    }
253
254    /// Returns a mutable reference to the internal VRAM (nametables).
255    pub fn vram_mut(&mut self) -> &mut [u8] {
256        self.bus.ppu.vram_mut()
257    }
258
259    /// Build a new emulator from raw ROM bytes (iNES 1.0 or NES 2.0).
260    ///
261    /// # Errors
262    ///
263    /// Returns the underlying [`RomError`] if the bytes don't parse.
264    pub fn from_rom(bytes: &[u8]) -> Result<Self, RomError> {
265        let mut bus = LockstepBus::new(bytes)?;
266        // Cold-boot path: `Cpu::power_on()` seeds `S=$00`; the subsequent
267        // `reset()`'s `S -= 3` (wrapping) lands at `$FD`, matching Mesen2's
268        // power-up state. See `docs/audit/session-13-cpu-boot-fix-2026-05-21.md`.
269        let mut cpu = Cpu::power_on();
270        cpu.reset(&mut bus);
271        Ok(Self {
272            cpu,
273            bus,
274            rom_sha256: sha256_of(bytes),
275            rewind: None,
276            rewind_capture_enabled: true,
277            rewind_snap_buf: Vec::new(),
278            #[cfg(feature = "cpu-boot-trace")]
279            cpu_boot_trace: None,
280            #[cfg(feature = "debug-hooks")]
281            breakpoints: Vec::new(),
282            #[cfg(feature = "debug-hooks")]
283            breakpoints_enabled: true,
284            #[cfg(feature = "debug-hooks")]
285            break_hit: None,
286            #[cfg(feature = "debug-hooks")]
287            skip_breakpoint_at: None,
288            #[cfg(feature = "debug-hooks")]
289            trace: alloc::collections::VecDeque::new(),
290            #[cfg(feature = "debug-hooks")]
291            trace_enabled: false,
292            #[cfg(feature = "debug-hooks")]
293            exec_log: Vec::new(),
294            #[cfg(feature = "debug-hooks")]
295            exec_logging: false,
296            timeline_generation: 0,
297        })
298    }
299
300    /// Build an emulator with an explicit audio sample rate (the rate the
301    /// CPAL stream is opened at).
302    ///
303    /// # Errors
304    ///
305    /// Returns the underlying [`RomError`] if the bytes don't parse.
306    pub fn from_rom_with_sample_rate(bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
307        let mut bus = LockstepBus::with_sample_rate(bytes, sample_rate)?;
308        // Cold-boot path: see comment in `from_rom`.
309        let mut cpu = Cpu::power_on();
310        cpu.reset(&mut bus);
311        Ok(Self {
312            cpu,
313            bus,
314            rom_sha256: sha256_of(bytes),
315            rewind: None,
316            rewind_capture_enabled: true,
317            rewind_snap_buf: Vec::new(),
318            #[cfg(feature = "cpu-boot-trace")]
319            cpu_boot_trace: None,
320            #[cfg(feature = "debug-hooks")]
321            breakpoints: Vec::new(),
322            #[cfg(feature = "debug-hooks")]
323            breakpoints_enabled: true,
324            #[cfg(feature = "debug-hooks")]
325            break_hit: None,
326            #[cfg(feature = "debug-hooks")]
327            skip_breakpoint_at: None,
328            #[cfg(feature = "debug-hooks")]
329            trace: alloc::collections::VecDeque::new(),
330            #[cfg(feature = "debug-hooks")]
331            trace_enabled: false,
332            #[cfg(feature = "debug-hooks")]
333            exec_log: Vec::new(),
334            #[cfg(feature = "debug-hooks")]
335            exec_logging: false,
336            timeline_generation: 0,
337        })
338    }
339
340    /// Build an emulator from a Famicom Disk System `.fds` disk image and a
341    /// user-supplied 8 KiB BIOS (`disksys.rom`).
342    ///
343    /// The BIOS is never committed to this repo (it is Nintendo IP); the caller
344    /// supplies it (a frontend BIOS prompt is Stage 2). Construction parses the
345    /// disk container, builds the FDS device as the bus's mapper, and runs the
346    /// standard cold-boot reset (the BIOS reset vector at `$FFFC` drives the
347    /// disk-load sequence).
348    ///
349    /// Uses the default 44.1 kHz audio sample rate; use
350    /// [`Nes::from_disk_with_sample_rate`] to pick the rate.
351    ///
352    /// # Errors
353    ///
354    /// Returns the underlying [`RomError`] if the disk image is unparseable or
355    /// the BIOS is not exactly 8 KiB.
356    pub fn from_disk(disk_bytes: &[u8], bios_bytes: &[u8]) -> Result<Self, RomError> {
357        Self::from_disk_with_sample_rate(disk_bytes, bios_bytes, crate::bus::DEFAULT_SAMPLE_RATE)
358    }
359
360    /// Build an FDS emulator with an explicit audio sample rate. See
361    /// [`Nes::from_disk`].
362    ///
363    /// The reported `rom_sha256` hashes the disk-image bytes (not the BIOS), so
364    /// save-states / movies key off the disk the way cartridge builds key off
365    /// the ROM.
366    ///
367    /// # Errors
368    ///
369    /// Returns the underlying [`RomError`] if the disk image is unparseable or
370    /// the BIOS is not exactly 8 KiB.
371    pub fn from_disk_with_sample_rate(
372        disk_bytes: &[u8],
373        bios_bytes: &[u8],
374        sample_rate: u32,
375    ) -> Result<Self, RomError> {
376        let mut bus = LockstepBus::with_disk(disk_bytes, bios_bytes, sample_rate)?;
377        // Cold-boot path: see comment in `from_rom`.
378        let mut cpu = Cpu::power_on();
379        cpu.reset(&mut bus);
380        Ok(Self {
381            cpu,
382            bus,
383            rom_sha256: sha256_of(disk_bytes),
384            rewind: None,
385            rewind_capture_enabled: true,
386            rewind_snap_buf: Vec::new(),
387            #[cfg(feature = "cpu-boot-trace")]
388            cpu_boot_trace: None,
389            #[cfg(feature = "debug-hooks")]
390            breakpoints: Vec::new(),
391            #[cfg(feature = "debug-hooks")]
392            breakpoints_enabled: true,
393            #[cfg(feature = "debug-hooks")]
394            break_hit: None,
395            #[cfg(feature = "debug-hooks")]
396            skip_breakpoint_at: None,
397            #[cfg(feature = "debug-hooks")]
398            trace: alloc::collections::VecDeque::new(),
399            #[cfg(feature = "debug-hooks")]
400            trace_enabled: false,
401            #[cfg(feature = "debug-hooks")]
402            exec_log: Vec::new(),
403            #[cfg(feature = "debug-hooks")]
404            exec_logging: false,
405            timeline_generation: 0,
406        })
407    }
408
409    /// Build an emulator that plays a classic NSF (`NESM`) music file.
410    ///
411    /// Only the classic `NESM\x1a` container is supported; `NSFe` and
412    /// expansion-chip audio are documented deferrals.
413    ///
414    /// NSF files carry a ripped NES sound engine plus an `init`/`play` address
415    /// pair, not a PPU program. Construction parses the file, installs a
416    /// [`rustynes_mappers::NsfMapper`] (a synthetic 6502 driver + the program
417    /// image) as the bus's mapper, and runs the standard cold-boot reset — the
418    /// driver's reset vector calls `init` for the starting song, enables vblank
419    /// NMI, and the ordinary 60 Hz NMI then calls `play` once per frame. Audio
420    /// is produced through the unchanged lockstep loop; there is no video.
421    ///
422    /// Uses the default 44.1 kHz sample rate; see
423    /// [`Nes::from_nsf_with_sample_rate`].
424    ///
425    /// # Errors
426    ///
427    /// Returns the underlying [`RomError`] when the NSF header is malformed.
428    pub fn from_nsf(nsf_bytes: &[u8]) -> Result<Self, RomError> {
429        Self::from_nsf_with_sample_rate(nsf_bytes, crate::bus::DEFAULT_SAMPLE_RATE)
430    }
431
432    /// Build an NSF player with an explicit audio sample rate. See
433    /// [`Nes::from_nsf`].
434    ///
435    /// # Errors
436    ///
437    /// Returns the underlying [`RomError`] when the NSF header is malformed.
438    pub fn from_nsf_with_sample_rate(nsf_bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
439        let mut bus = LockstepBus::with_nsf(nsf_bytes, sample_rate)?;
440        let mut cpu = Cpu::power_on();
441        cpu.reset(&mut bus);
442        Ok(Self {
443            cpu,
444            bus,
445            rom_sha256: sha256_of(nsf_bytes),
446            rewind: None,
447            rewind_capture_enabled: true,
448            rewind_snap_buf: Vec::new(),
449            #[cfg(feature = "cpu-boot-trace")]
450            cpu_boot_trace: None,
451            #[cfg(feature = "debug-hooks")]
452            breakpoints: Vec::new(),
453            #[cfg(feature = "debug-hooks")]
454            breakpoints_enabled: true,
455            #[cfg(feature = "debug-hooks")]
456            break_hit: None,
457            #[cfg(feature = "debug-hooks")]
458            skip_breakpoint_at: None,
459            #[cfg(feature = "debug-hooks")]
460            trace: alloc::collections::VecDeque::new(),
461            #[cfg(feature = "debug-hooks")]
462            trace_enabled: false,
463            #[cfg(feature = "debug-hooks")]
464            exec_log: Vec::new(),
465            #[cfg(feature = "debug-hooks")]
466            exec_logging: false,
467            timeline_generation: 0,
468        })
469    }
470
471    /// Number of selectable songs in the loaded NSF (0 for a cartridge / disk).
472    #[must_use]
473    pub fn nsf_song_count(&self) -> u8 {
474        self.bus.nsf_song_count()
475    }
476
477    /// The currently-selected 0-based NSF song (0 for a cartridge / disk).
478    #[must_use]
479    pub fn nsf_current_song(&self) -> u8 {
480        self.bus.nsf_current_song()
481    }
482
483    /// Select a 0-based NSF song and restart playback on it (re-runs `init` via
484    /// a warm reset). No-op for a cartridge / disk.
485    pub fn nsf_set_song(&mut self, song: u8) {
486        if self.bus.nsf_set_song(song) {
487            // Re-vector through the driver's reset entry so `init` runs for the
488            // new track. Warm reset preserves the freshly-patched driver state.
489            self.reset();
490        }
491    }
492
493    /// Build an emulator with a **randomized power-on RAM** state (developer
494    /// mode; Phase 7 / T-72-005).
495    ///
496    /// Identical to [`Nes::from_rom`] except the 2 KiB CPU work RAM and the
497    /// open-bus latch are filled from a deterministic `xorshift64` PRNG keyed
498    /// on `seed`, modelling the unreliable power-on RAM of real hardware
499    /// (nesdev "CPU power up state"). Use this to shake out game/test code
500    /// that depends on a particular post-power-on RAM pattern.
501    ///
502    /// The randomization is **seeded and deterministic** — the same `seed`
503    /// yields the same state, so the `same seed + ROM + input ⇒ bit-identical`
504    /// contract still holds. The default [`Nes::from_rom`] (zeroed RAM) is
505    /// what CI, the regression oracle, and save-state tests use.
506    ///
507    /// # Errors
508    ///
509    /// Returns the underlying [`RomError`] if the bytes don't parse.
510    pub fn from_rom_with_power_on_seed(bytes: &[u8], seed: u64) -> Result<Self, RomError> {
511        Self::from_rom_with_power_on_config(
512            bytes,
513            PowerOnConfig {
514                ram: PowerOnRam::Seeded(seed),
515            },
516        )
517    }
518
519    /// v2.1.7 P5 — build an emulator with an explicit [`PowerOnConfig`].
520    ///
521    /// Generalizes [`Nes::from_rom_with_power_on_seed`]: the caller chooses the
522    /// power-on work-RAM fill ([`PowerOnRam::Zeroed`] / [`PowerOnRam::Seeded`] /
523    /// [`PowerOnRam::Filled`]). The config is stored on the bus so a subsequent
524    /// power-cycle re-applies the same fill (keeping `power_cycle == fresh
525    /// boot`). All fills are **deterministic**, so the `same config + ROM + input
526    /// ⇒ bit-identical` contract still holds. [`PowerOnConfig::default`]
527    /// ([`PowerOnRam::Zeroed`]) is byte-identical to [`Nes::from_rom`].
528    ///
529    /// # Errors
530    ///
531    /// Returns the underlying [`RomError`] if the bytes don't parse.
532    pub fn from_rom_with_power_on_config(
533        bytes: &[u8],
534        config: PowerOnConfig,
535    ) -> Result<Self, RomError> {
536        let mut nes = Self::from_rom(bytes)?;
537        // RAM is not consulted during the reset sequence (only the $FFFC/D
538        // vector is), so applying the fill after construction is correct.
539        nes.bus.set_power_on_ram(config.ram);
540        Ok(nes)
541    }
542
543    /// Reset (warm boot). Preserves WRAM; reloads PC from `$FFFC/D`.
544    pub fn reset(&mut self) {
545        // v2.4.0 item B — a warm reset lands somewhere else on the timeline, so a
546        // reconstructed call stack and the access counters describe a run that no
547        // longer exists.
548        self.timeline_generation = self.timeline_generation.wrapping_add(1);
549        self.bus.reset();
550        self.cpu.reset(&mut self.bus);
551    }
552
553    /// Power-cycle (cold boot). Zeroes WRAM, re-rolls phase, reloads vectors.
554    pub fn power_cycle(&mut self) {
555        // v2.4.0 item B — see `reset`; a cold boot is the larger discontinuity.
556        self.timeline_generation = self.timeline_generation.wrapping_add(1);
557        self.bus.power_cycle();
558        // Cold-boot path: see comment in `from_rom`.
559        self.cpu = Cpu::power_on();
560        self.cpu.reset(&mut self.bus);
561        // v2.3.2 "Lucid" — a cold boot ends the history both provenance stores
562        // describe. Keep them armed (the user asked for them) but empty.
563        #[cfg(feature = "debug-hooks")]
564        {
565            self.bus.ppu.clear_write_attribution();
566            self.bus.ppu.clear_pixel_provenance();
567            // v2.3.7 — same for audio: a cold boot ends the history the
568            // register attribution describes.
569            self.bus.apu.clear_audio_provenance_history();
570        }
571    }
572
573    /// Run until the PPU finishes a frame. Returns the framebuffer slice.
574    ///
575    /// # Panics
576    ///
577    /// Panics if the CPU JAMs without producing a frame. Real software
578    /// shouldn't JAM; if it does, the caller's run-loop should catch it
579    /// before the next frame.
580    pub fn run_frame(&mut self) -> &[u8] {
581        // Hard cap: at NTSC the frame budget is 29,780.5 CPU cycles. Run
582        // up to 5x that before bailing — gives breathing room for late
583        // VBL detection or DMA-stall heavy frames before declaring "stuck".
584        const MAX_CYCLES_PER_FRAME: u64 = 150_000;
585        let start = self.bus.cycle();
586        // v2.3.7 "Overtone" — anchor this frame's mix trace. The trace is
587        // per-frame (the index IS the cycle offset from here); the REGISTER
588        // attribution deliberately is not, because "which instruction last wrote
589        // $4003" has an answer that legitimately predates this frame.
590        #[cfg(feature = "debug-hooks")]
591        self.bus.apu.begin_audio_provenance_frame(start);
592        // T-110-C3 — the event viewer shows one frame; reset the log per frame.
593        #[cfg(feature = "debug-hooks")]
594        if self.bus.event_logging() {
595            self.bus.clear_events();
596        }
597        // T-110-E2 — the Lua onRead/onWrite access log is per-frame too.
598        #[cfg(feature = "debug-hooks")]
599        if self.bus.access_logging() {
600            self.bus.clear_accesses();
601        }
602        // T-110-E1 — the Lua onNmi/onIrq interrupt-service log is per-frame too
603        // (cleared here so a replay only ever sees this frame's services, never
604        // a stale carry-over — mirrors the exec_log clear below).
605        #[cfg(feature = "debug-hooks")]
606        if self.bus.interrupt_logging() {
607            self.bus.clear_interrupts();
608        }
609        // v1.6.0 Workstream A3 — reset the `TAStudio` lag-log "controller polled"
610        // flag so was_input_polled_this_frame() reflects only this frame (a
611        // frame that ends still-`false` is a lag frame). Output-only.
612        #[cfg(feature = "debug-hooks")]
613        self.bus.clear_controller_polled();
614        // v1.4.0 Workstream D (D2) — start each frame with no event-breakpoint
615        // hit so the frontend's "first hit of the frame" pause is per-frame.
616        #[cfg(feature = "debug-hooks")]
617        if self.bus.event_breakpoints() != 0 {
618            self.bus.clear_event_break_hit();
619        }
620        // T-110-E2 — the Lua onExec exec-PC log is per-frame (cleared here so a
621        // replay only ever sees this frame's PCs, never a stale carry-over).
622        #[cfg(feature = "debug-hooks")]
623        if self.exec_logging {
624            self.exec_log.clear();
625        }
626        while !self.bus.take_frame_complete() {
627            if self.cpu.is_jammed() {
628                break;
629            }
630            if self.bus.cycle().wrapping_sub(start) > MAX_CYCLES_PER_FRAME {
631                break;
632            }
633            #[cfg(feature = "debug-hooks")]
634            {
635                // v1.1.0 beta.2 (Workstream C) — exec/PC breakpoints. The
636                // `skip_breakpoint_at` PC is stepped past exactly once (so a
637                // "continue" resumes off the instruction it stopped on instead
638                // of re-breaking in place); any OTHER breakpoint PC — including
639                // one at the frame's starting PC after a reset / save-state load
640                // / manual PC change — still fires immediately.
641                if self.breakpoints_enabled && self.breakpoints.contains(&self.cpu.pc) {
642                    if self.skip_breakpoint_at == Some(self.cpu.pc) {
643                        self.skip_breakpoint_at = None;
644                    } else {
645                        // Hit: stop the (partial) frame and report the PC. No
646                        // state mutated; the frame simply isn't completed.
647                        self.break_hit = Some(self.cpu.pc);
648                        self.skip_breakpoint_at = Some(self.cpu.pc);
649                        return self.bus.framebuffer();
650                    }
651                } else {
652                    self.skip_breakpoint_at = None;
653                }
654                // T-110-E2 — per-frame exec-PC log for Lua onExec (bounded by
655                // MAX_CYCLES_PER_FRAME, so no explicit cap needed).
656                if self.exec_logging {
657                    self.exec_log.push(self.cpu.pc);
658                }
659                // v2.3.2 "Lucid" — push this instruction's `(pc, cycle)` down to
660                // the PPU so any CIRAM / OAM / palette byte it goes on to write
661                // is stamped with the instruction that caused it. Done here, in
662                // the existing per-instruction debug block, rather than through a
663                // new `CpuBus` hook: `run_frame` already holds both halves, so
664                // this costs two stores and leaves `rustynes-cpu` untouched.
665                //
666                // Unconditional rather than gated on the store being armed: the
667                // "is it armed?" question lives behind a `Box` on the other side
668                // of a crate boundary, so testing it would cost about as much as
669                // the two stores it would skip, in a block that already runs a
670                // breakpoint scan per instruction.
671                self.bus
672                    .ppu
673                    .set_attrib_context(self.cpu.pc, self.cpu.cycles);
674                // v2.3.7 "Overtone" — the same push-down for audio, so
675                // `Apu::write_register` can attribute a `$4000-$4017` write
676                // without `rustynes-cpu` knowing the feature exists. Same
677                // reasoning as the PPU line above: two unconditional stores are
678                // cheaper than testing an arm behind a `Box` across a crate
679                // boundary, in a block that already scans breakpoints.
680                self.bus
681                    .apu
682                    .set_attrib_context(self.cpu.pc, self.cpu.cycles);
683                // T-110-C2 — cycle trace: record the about-to-execute
684                // instruction's CPU state (ring-capped, oldest dropped).
685                if self.trace_enabled {
686                    if self.trace.len() >= Self::TRACE_CAP {
687                        self.trace.pop_front();
688                    }
689                    self.trace.push_back(TraceRec {
690                        pc: self.cpu.pc,
691                        a: self.cpu.a,
692                        x: self.cpu.x,
693                        y: self.cpu.y,
694                        s: self.cpu.s,
695                        p: self.cpu.p.bits(),
696                        cycle: self.cpu.cycles,
697                    });
698                }
699            }
700            #[cfg(feature = "cpu-boot-trace")]
701            self.cpu_boot_trace_record();
702            self.cpu.step(&mut self.bus);
703        }
704        // Sample any attached Zapper's light detection from the completed
705        // frame. This is a no-op (and the run loop above is byte-identical)
706        // when no Zapper is attached, so the determinism contract holds.
707        self.bus.sample_zapper_light();
708        // After the frame completes, push state into the rewind ring so
709        // the frontend's hold-F5 UX has somewhere to walk back from.
710        // v2.8.0 Phase 3 — run-ahead suppresses the capture for its hidden
711        // + visible frames via `set_rewind_capture(false)`.
712        if self.rewind.is_some() && self.rewind_capture_enabled {
713            self.rewind_capture();
714        }
715        self.bus.framebuffer()
716    }
717
718    /// v2.8.0 Phase 3 — enable/disable the per-frame rewind capture while
719    /// the ring stays armed. Run-ahead turns it off around its hidden +
720    /// visible frames so only persistent-timeline frames land in the ring.
721    /// Default `true`; with no rewind ring armed this is a no-op.
722    pub const fn set_rewind_capture(&mut self, enabled: bool) {
723        self.rewind_capture_enabled = enabled;
724    }
725
726    /// Whether the per-frame rewind capture is currently armed.
727    ///
728    /// Added in v2.3.6 so a caller that needs to suppress capture temporarily can
729    /// save and restore the *caller's* setting rather than assume the default.
730    /// `rustynes-probe` does exactly that around a trial: its replayed frames
731    /// never happened on the user's timeline, so they must not enter the ring —
732    /// but nor may re-enabling capture afterwards turn it on for someone who had
733    /// deliberately turned it off. Run-ahead predates this and still restores an
734    /// unconditional `true`, which is correct only because nothing else disables
735    /// capture today.
736    #[must_use]
737    pub const fn rewind_capture_enabled(&self) -> bool {
738        self.rewind_capture_enabled
739    }
740
741    /// Step exactly one CPU instruction. For debuggers / step-through tools.
742    pub fn step_instruction(&mut self) -> u8 {
743        #[cfg(feature = "cpu-boot-trace")]
744        self.cpu_boot_trace_record();
745        // v2.3.2 "Lucid" — mirror `run_frame`'s write-attribution context push,
746        // so single-stepping through a `$2007` store in the debugger attributes
747        // the byte to the stepped instruction and not to whatever `run_frame`
748        // last left latched.
749        #[cfg(feature = "debug-hooks")]
750        self.bus
751            .ppu
752            .set_attrib_context(self.cpu.pc, self.cpu.cycles);
753        #[cfg(feature = "debug-hooks")]
754        self.bus
755            .apu
756            .set_attrib_context(self.cpu.pc, self.cpu.cycles);
757        self.cpu.step(&mut self.bus)
758    }
759
760    /// v1.1.0 beta.2 (Workstream C) — add an exec/PC breakpoint at `addr`.
761    /// [`Nes::run_frame`] stops the frame the next time the program counter
762    /// reaches `addr` (reportable via [`Nes::take_break_hit`]). Idempotent.
763    /// `debug-hooks` only.
764    #[cfg(feature = "debug-hooks")]
765    pub fn add_breakpoint(&mut self, addr: u16) {
766        if !self.breakpoints.contains(&addr) {
767            self.breakpoints.push(addr);
768        }
769    }
770
771    /// Remove a previously-added exec breakpoint (no-op if absent).
772    #[cfg(feature = "debug-hooks")]
773    pub fn remove_breakpoint(&mut self, addr: u16) {
774        self.breakpoints.retain(|&a| a != addr);
775    }
776
777    /// Remove all breakpoints.
778    #[cfg(feature = "debug-hooks")]
779    pub fn clear_breakpoints(&mut self) {
780        self.breakpoints.clear();
781    }
782
783    /// The current exec breakpoints (insertion order).
784    #[cfg(feature = "debug-hooks")]
785    #[must_use]
786    // `Vec` -> slice deref coercion is not const, so this can't be `const fn`
787    // (clippy's `missing_const_for_fn` is a false positive here).
788    #[allow(clippy::missing_const_for_fn)]
789    pub fn breakpoints(&self) -> &[u16] {
790        &self.breakpoints
791    }
792
793    /// Arm/disarm breakpoint checking without discarding the list. Default on.
794    #[cfg(feature = "debug-hooks")]
795    pub const fn set_breakpoints_enabled(&mut self, enabled: bool) {
796        self.breakpoints_enabled = enabled;
797    }
798
799    /// Whether breakpoint checking is armed.
800    #[cfg(feature = "debug-hooks")]
801    #[must_use]
802    pub const fn breakpoints_enabled(&self) -> bool {
803        self.breakpoints_enabled
804    }
805
806    /// Take the PC that last hit a breakpoint (cleared on read). The frontend
807    /// polls this after [`Nes::run_frame`] to pause when a breakpoint fired.
808    #[cfg(feature = "debug-hooks")]
809    pub const fn take_break_hit(&mut self) -> Option<u16> {
810        self.break_hit.take()
811    }
812
813    /// v1.4.0 Workstream D (D2) — arm the event-driven breakpoint categories
814    /// (a bit-OR of [`crate::EventBpKind::bit`]). `0` (default) disarms every
815    /// category — the per-access taps are then a single cheap `mask == 0`
816    /// early-out. Output-only: a hit pauses + reports but never mutates state.
817    /// `debug-hooks` only.
818    #[cfg(feature = "debug-hooks")]
819    pub const fn set_event_breakpoints(&mut self, mask: u16) {
820        self.bus.set_event_breakpoints(mask);
821    }
822
823    /// The armed event-breakpoint category mask.
824    #[cfg(feature = "debug-hooks")]
825    #[must_use]
826    pub const fn event_breakpoints(&self) -> u16 {
827        self.bus.event_breakpoints()
828    }
829
830    /// Take the first event-breakpoint hit of the current frame (cleared on
831    /// read). The frontend polls this after [`Nes::run_frame`] to pause when an
832    /// armed hardware event fired, reporting its kind + frame/cycle/scanline/dot.
833    #[cfg(feature = "debug-hooks")]
834    pub const fn take_event_break_hit(&mut self) -> Option<crate::EventBreakHit> {
835        self.bus.take_event_break_hit()
836    }
837
838    /// Maximum cycle-trace ring depth (oldest records drop past this).
839    #[cfg(feature = "debug-hooks")]
840    pub const TRACE_CAP: usize = 50_000;
841
842    /// v1.1.0 beta.2 (T-110-C2) — start/stop the cycle-trace logger. While on,
843    /// each executed instruction's CPU state is pushed to a ring buffer (capped
844    /// at [`Self::TRACE_CAP`]). Default off.
845    #[cfg(feature = "debug-hooks")]
846    pub const fn set_trace_enabled(&mut self, enabled: bool) {
847        self.trace_enabled = enabled;
848    }
849
850    /// Whether the cycle-trace logger is recording.
851    #[cfg(feature = "debug-hooks")]
852    #[must_use]
853    pub const fn trace_enabled(&self) -> bool {
854        self.trace_enabled
855    }
856
857    /// Number of records currently in the trace ring.
858    #[cfg(feature = "debug-hooks")]
859    #[must_use]
860    pub fn trace_len(&self) -> usize {
861        self.trace.len()
862    }
863
864    /// Clear the trace ring.
865    #[cfg(feature = "debug-hooks")]
866    pub fn clear_trace(&mut self) {
867        self.trace.clear();
868    }
869
870    /// Copy the trace ring oldest-first (for the trace panel / file export).
871    #[cfg(feature = "debug-hooks")]
872    #[must_use]
873    pub fn trace_records(&self) -> alloc::vec::Vec<TraceRec> {
874        self.trace.iter().copied().collect()
875    }
876
877    /// Copy the most recent `n` trace records (oldest-first) — for the live
878    /// trace panel's tail view, cheaper than [`Self::trace_records`] on a full
879    /// ring.
880    #[cfg(feature = "debug-hooks")]
881    #[must_use]
882    pub fn trace_tail_vec(&self, n: usize) -> alloc::vec::Vec<TraceRec> {
883        let skip = self.trace.len().saturating_sub(n);
884        self.trace.iter().skip(skip).copied().collect()
885    }
886
887    /// v1.1.0 beta.2 (T-110-C3) — start/stop the event viewer. While on, the bus
888    /// records this frame's PPU/APU/mapper writes (with their PPU position); the
889    /// log is reset at each [`Self::run_frame`]. Default off; output-only.
890    #[cfg(feature = "debug-hooks")]
891    pub const fn set_event_logging(&mut self, enabled: bool) {
892        self.bus.set_event_logging(enabled);
893    }
894
895    /// Whether the event viewer is recording.
896    #[cfg(feature = "debug-hooks")]
897    #[must_use]
898    pub const fn event_logging(&self) -> bool {
899        self.bus.event_logging()
900    }
901
902    /// The current frame's captured events (for the event-viewer panel).
903    #[cfg(feature = "debug-hooks")]
904    #[must_use]
905    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
906    pub fn events(&self) -> &[crate::bus::EventRec] {
907        self.bus.events()
908    }
909
910    /// v2.3.2 "Lucid" — arm or disarm per-byte **write attribution** for the
911    /// PPU's own memories (CIRAM, OAM, palette RAM).
912    ///
913    /// While armed, every write to those memories is stamped with the program
914    /// counter and CPU cycle of the instruction that performed it, which is the
915    /// edge the pixel-provenance panel walks to answer "which instruction put
916    /// this byte here?". The Event Viewer records the CPU-side `$2000-$3FFF`
917    /// write and the memory-access counter records a cycle stamp, but neither
918    /// carries the PC *and* the resolved destination — see
919    /// [`rustynes_ppu::provenance`] for why the two halves are recorded on
920    /// opposite sides of the bus.
921    ///
922    /// Default off. Arming allocates
923    /// [`rustynes_ppu::WriteAttribution::HEAP_BYTES`]; disarming frees it.
924    /// Output-only, so emulation is bit-identical either way.
925    #[cfg(feature = "debug-hooks")]
926    pub fn set_write_attribution(&mut self, enabled: bool) {
927        self.bus.ppu.set_write_attribution(enabled);
928    }
929
930    /// The write-attribution store, or `None` when not armed.
931    #[cfg(feature = "debug-hooks")]
932    #[must_use]
933    pub fn write_attribution(&self) -> Option<&rustynes_ppu::WriteAttribution> {
934        self.bus.ppu.write_attribution()
935    }
936
937    /// Forget the current frame's per-pixel provenance, keeping it armed.
938    ///
939    /// Called automatically on power-cycle and on both restore paths; exposed so
940    /// a host that rewinds by other means can do the same.
941    #[cfg(feature = "debug-hooks")]
942    pub fn clear_pixel_provenance(&mut self) {
943        self.bus.ppu.clear_pixel_provenance();
944    }
945
946    /// Forget every recorded write attribution, keeping the store armed.
947    ///
948    /// Call this after a save-state restore or a power-cycle: the restored bytes
949    /// were not written by any instruction this session executed, and reporting
950    /// the PCs that wrote those offsets before the restore would be a
951    /// confidently wrong answer rather than an absent one.
952    #[cfg(feature = "debug-hooks")]
953    pub fn clear_write_attribution(&mut self) {
954        self.bus.ppu.clear_write_attribution();
955    }
956
957    /// v2.3.2 "Lucid" phase 2 — arm or disarm **per-pixel provenance**.
958    ///
959    /// While armed, every emitted pixel records the layer that won the priority
960    /// decision, the exact `$3Fxx` palette address behind its color, and the
961    /// nametable / attribute / pattern addresses of the tile **actually on
962    /// screen** — which `v` cannot answer, because by display time it has
963    /// advanced two tiles past the pixel.
964    ///
965    /// Composes with [`Self::set_write_attribution`]: provenance says which
966    /// bytes produced a pixel, attribution says which instruction wrote them.
967    /// Each is useful alone; together they are the full causal chain.
968    ///
969    /// Default off. Arming allocates
970    /// [`rustynes_ppu::PixelProvenanceFrame::HEAP_BYTES`]. Output-only, so
971    /// emulation is bit-identical either way.
972    #[cfg(feature = "debug-hooks")]
973    pub fn set_pixel_provenance(&mut self, enabled: bool) {
974        self.bus.ppu.set_pixel_provenance(enabled);
975    }
976
977    /// The current frame's per-pixel provenance, or `None` when not armed.
978    #[cfg(feature = "debug-hooks")]
979    #[must_use]
980    pub fn pixel_provenance(&self) -> Option<&rustynes_ppu::PixelProvenanceFrame> {
981        self.bus.ppu.pixel_provenance()
982    }
983
984    /// Arm or disarm **audio** provenance (v2.3.7 "Overtone").
985    ///
986    /// Off by default. Arming allocates the per-register write attribution and
987    /// the per-CPU-cycle mix trace; disarming frees both. Output-only — nothing
988    /// recorded is read back into synthesis or carried in the save state, so the
989    /// deterministic audio contract is unaffected either way.
990    #[cfg(feature = "debug-hooks")]
991    pub fn set_audio_provenance(&mut self, enabled: bool) {
992        self.bus.apu.set_audio_provenance(enabled);
993    }
994
995    /// Whether audio provenance is armed.
996    #[cfg(feature = "debug-hooks")]
997    #[must_use]
998    pub const fn audio_provenance_armed(&self) -> bool {
999        self.bus.apu.audio_provenance_armed()
1000    }
1001
1002    /// The per-register write attribution — which instruction last wrote each of
1003    /// `$4000-$4017` — or `None` when disarmed.
1004    #[cfg(feature = "debug-hooks")]
1005    #[must_use]
1006    pub fn register_attribution(&self) -> Option<&rustynes_apu::provenance::RegisterAttribution> {
1007        self.bus.apu.register_attribution()
1008    }
1009
1010    /// This frame's per-CPU-cycle mix trace, or `None` when disarmed.
1011    #[cfg(feature = "debug-hooks")]
1012    #[must_use]
1013    pub fn mix_trace(&self) -> Option<&rustynes_apu::provenance::MixTrace> {
1014        self.bus.apu.mix_trace()
1015    }
1016
1017    /// Lift the audio provenance stores out for a same-timeline restore.
1018    ///
1019    /// The audio counterpart of [`Self::take_provenance`], and it exists for the
1020    /// identical reason: run-ahead's rollback runs AFTER the visible frame is
1021    /// produced and BEFORE the frontend releases the emulator lock, so a store
1022    /// the restore clears can never be observed by the UI. That is exactly how
1023    /// Pixel Provenance shipped non-functional from v2.3.2 to v2.3.6. Take
1024    /// before the restore, [`Self::put_audio_provenance`] after.
1025    ///
1026    /// Save-state loads and netplay rollback still clear, unchanged — those are
1027    /// genuine timeline changes. Run-ahead's rollback is not.
1028    #[cfg(feature = "debug-hooks")]
1029    #[must_use]
1030    pub fn take_audio_provenance(&mut self) -> rustynes_apu::provenance::AudioProvenanceStash {
1031        self.bus.apu.take_audio_provenance()
1032    }
1033
1034    /// Put back stores taken by [`Self::take_audio_provenance`].
1035    #[cfg(feature = "debug-hooks")]
1036    pub fn put_audio_provenance(&mut self, stash: rustynes_apu::provenance::AudioProvenanceStash) {
1037        self.bus.apu.put_audio_provenance(stash);
1038    }
1039
1040    /// v2.3.6 — move both provenance stores out, leaving them unarmed.
1041    ///
1042    /// For a host that performs a **same-timeline** restore whose result the user
1043    /// is about to inspect. [`Self::restore`] and [`Self::restore_quiet`] both
1044    /// clear the stores, which is correct when the restore replaces the timeline
1045    /// the records describe — and wrong for run-ahead, whose rollback is the last
1046    /// thing before the UI reads, so the clear discards the record for the frame
1047    /// actually on screen. Take before the restore, [`Self::put_provenance`]
1048    /// after.
1049    ///
1050    /// A move, not a copy: the stores are boxed, so this is two pointer moves.
1051    /// See [`rustynes_ppu::ProvenanceStash`].
1052    #[cfg(feature = "debug-hooks")]
1053    pub const fn take_provenance(&mut self) -> rustynes_ppu::ProvenanceStash {
1054        self.bus.ppu.take_provenance()
1055    }
1056
1057    /// Put back stores taken by [`Self::take_provenance`].
1058    #[cfg(feature = "debug-hooks")]
1059    pub fn put_provenance(&mut self, stash: rustynes_ppu::ProvenanceStash) {
1060        self.bus.ppu.put_provenance(stash);
1061    }
1062
1063    /// Resolve a PPU-space nametable address (`$2000-$3EFF`) to the physical
1064    /// internal-CIRAM offset it reads, applying the mapper's mirroring and any
1065    /// per-game mirroring override.
1066    ///
1067    /// This is what turns a [`rustynes_ppu::PixelProvenance::nt_addr`] into the
1068    /// key [`rustynes_ppu::WriteAttribution::ciram`] is indexed by, so the
1069    /// provenance panel can go from "this pixel's tile came from `$2002`" to
1070    /// "and instruction X wrote that byte" without reimplementing mirroring.
1071    ///
1072    /// Returns `None` for an address outside the nametable window.
1073    ///
1074    /// # Boards with mapper-supplied nametable memory
1075    ///
1076    /// On `MMC5` (`ExRAM` nametables) and 4-screen boards, some nametable writes are
1077    /// absorbed by the mapper via `PpuBus::write_nametable` and never reach
1078    /// internal CIRAM. This function still returns the CIRAM offset the standard
1079    /// mirroring would select, because the only way to know whether the mapper
1080    /// absorbed a particular write is to *perform* one — `write_nametable` takes
1081    /// `&mut self` and has side effects, and a read-only query has no business
1082    /// inventing one. Callers should treat a missing attribution on such a board
1083    /// as "the mapper owns this byte", which is what it means.
1084    #[cfg(feature = "debug-hooks")]
1085    #[must_use]
1086    pub fn ciram_offset_for_nametable_addr(&self, addr: u16) -> Option<usize> {
1087        let a = addr & 0x3FFF;
1088        if !(0x2000..0x3F00).contains(&a) {
1089            return None;
1090        }
1091        let nt_addr = if a >= 0x3000 { a - 0x1000 } else { a };
1092        Some((self.bus.resolve_nametable_address(nt_addr) as usize) & 0x07FF)
1093    }
1094
1095    /// v1.1.0 beta.3 (T-110-E2) — start/stop the Lua bus-access log. While on,
1096    /// the bus records this frame's CPU reads + writes (with values); the log is
1097    /// reset at each [`Self::run_frame`]. Default off; output-only. Enabled by
1098    /// the scripting engine only while `onRead`/`onWrite` callbacks exist.
1099    #[cfg(feature = "debug-hooks")]
1100    pub const fn set_access_logging(&mut self, enabled: bool) {
1101        self.bus.set_access_logging(enabled);
1102    }
1103
1104    /// Whether the bus-access log is recording.
1105    #[cfg(feature = "debug-hooks")]
1106    #[must_use]
1107    pub const fn access_logging(&self) -> bool {
1108        self.bus.access_logging()
1109    }
1110
1111    /// The current frame's captured CPU bus accesses (for the Lua engine).
1112    #[cfg(feature = "debug-hooks")]
1113    #[must_use]
1114    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
1115    pub fn accesses(&self) -> &[crate::bus::AccessRec] {
1116        self.bus.accesses()
1117    }
1118
1119    /// v1.1.0 beta.3 (T-110-E2) — start/stop the per-frame exec-PC log for the
1120    /// Lua `onExec` callback. Independent of the Trace Logger (`set_trace_enabled`),
1121    /// so enabling it does not disturb the debugger's trace recording. Cleared
1122    /// every [`Self::run_frame`]; output-only.
1123    #[cfg(feature = "debug-hooks")]
1124    pub const fn set_exec_logging(&mut self, enabled: bool) {
1125        self.exec_logging = enabled;
1126    }
1127
1128    /// Whether the per-frame exec-PC log is recording.
1129    #[cfg(feature = "debug-hooks")]
1130    #[must_use]
1131    pub const fn exec_logging(&self) -> bool {
1132        self.exec_logging
1133    }
1134
1135    /// This frame's executed PCs, in execution order (for the Lua engine).
1136    #[cfg(feature = "debug-hooks")]
1137    #[must_use]
1138    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
1139    pub fn exec_log(&self) -> &[u16] {
1140        &self.exec_log
1141    }
1142
1143    /// `true` if the running program read a controller port (`$4016`/`$4017`)
1144    /// during the most recent [`Self::run_frame`] — the inverse of a `TAStudio`
1145    /// "lag frame" (v1.6.0 Workstream A3). The greenzone / piano-roll lag log
1146    /// queries this each frame. Output-only; `debug-hooks`-gated, so the
1147    /// shipped build is byte-identical and the determinism contract holds.
1148    #[cfg(feature = "debug-hooks")]
1149    #[must_use]
1150    pub const fn was_input_polled_this_frame(&self) -> bool {
1151        self.bus.controller_polled()
1152    }
1153
1154    /// v1.2.0 (T-110-E1) — start/stop the per-frame interrupt-service log for
1155    /// the Lua `onNmi` / `onIrq` callbacks. The log records this frame's
1156    /// committed NMI / IRQ / BRK service entries (captured at the CPU's
1157    /// service-vector commit point, NOT the speculative poll sampler); it is
1158    /// cleared at each [`Self::run_frame`]. Default off; output-only. Enabled by
1159    /// the scripting engine only while `onNmi`/`onIrq` callbacks exist. Mirrors
1160    /// [`Self::set_exec_logging`].
1161    #[cfg(feature = "debug-hooks")]
1162    pub const fn set_interrupt_logging(&mut self, enabled: bool) {
1163        self.bus.set_interrupt_logging(enabled);
1164    }
1165
1166    /// Whether the per-frame interrupt-service log is recording.
1167    #[cfg(feature = "debug-hooks")]
1168    #[must_use]
1169    pub const fn interrupt_logging(&self) -> bool {
1170        self.bus.interrupt_logging()
1171    }
1172
1173    /// This frame's committed interrupt-service entries, in service order (for
1174    /// the Lua engine). Mirrors [`Self::exec_log`] / [`Self::accesses`].
1175    #[cfg(feature = "debug-hooks")]
1176    #[must_use]
1177    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
1178    pub fn interrupt_log(&self) -> &[crate::bus::InterruptRec] {
1179        self.bus.interrupts()
1180    }
1181
1182    /// Borrow the framebuffer (RGBA8, 256x240).
1183    #[must_use]
1184    pub fn framebuffer(&self) -> &[u8] {
1185        self.bus.framebuffer()
1186    }
1187
1188    /// v1.7.0 "Forge" Workstream B (B3) — overwrite the RGBA8 output framebuffer
1189    /// (the Lua `emu:setScreenBuffer(t)` paints output only). Output-only; see
1190    /// [`rustynes_ppu::Ppu::debug_set_framebuffer`]. Reached only through the
1191    /// script crate's gated post-frame path, so the shipped build is
1192    /// byte-identical and the determinism contract holds. `debug-hooks`-gated.
1193    #[cfg(feature = "debug-hooks")]
1194    pub fn debug_set_framebuffer(&mut self, rgba: &[u8]) {
1195        self.bus.debug_set_framebuffer(rgba);
1196    }
1197
1198    /// Borrow the parallel palette-index framebuffer (256x240 `u16`s, each
1199    /// `(emphasis << 6) | colour`) for the `NES_NTSC` composite filter.
1200    /// See [`rustynes_ppu::Ppu::index_framebuffer`].
1201    #[must_use]
1202    pub fn index_framebuffer(&self) -> &[u16] {
1203        self.bus.index_framebuffer()
1204    }
1205
1206    /// v1.2.0 C3 (hd-pack) — borrow the per-pixel HD-pack tile-source buffer
1207    /// (256x240 [`rustynes_ppu::HdTileSource`] records). Each entry names the
1208    /// CHR tile that produced the pixel; the frontend HD-pack loader groups
1209    /// these by 8x8 cell, hashes the CHR bytes, and substitutes hi-res tiles.
1210    /// Output-only telemetry; the determinism contract is unaffected. See
1211    /// [`rustynes_ppu::Ppu::hd_tile_source`].
1212    #[cfg(feature = "hd-pack")]
1213    #[must_use]
1214    pub fn hd_tile_source(&self) -> &[rustynes_ppu::HdTileSource] {
1215        self.bus.hd_tile_source()
1216    }
1217
1218    /// The frame's background scroll `(x, y)` in NES pixels, for offsetting
1219    /// parallax HD-pack `<background>` layers (see
1220    /// [`rustynes_ppu::Ppu::hd_bg_scroll`]). Output-only.
1221    #[cfg(feature = "hd-pack")]
1222    #[must_use]
1223    pub const fn hd_bg_scroll(&self) -> (i32, i32) {
1224        self.bus.ppu().hd_bg_scroll()
1225    }
1226
1227    /// The per-frame NTSC composite colour phase consumed by the `NES_NTSC`
1228    /// filter (`0..=2` on NTSC; frame parity `0..=1` on PAL/Dendy). See
1229    /// [`rustynes_ppu::Ppu::ntsc_phase`].
1230    #[must_use]
1231    pub const fn ntsc_phase(&self) -> u8 {
1232        self.bus.ntsc_phase()
1233    }
1234
1235    /// The completed-frame counter (PPU frames since power-on). A monotonic,
1236    /// deterministic, save-state-restored value — the frontend uses it to phase
1237    /// turbo/autofire so the strobe is reproducible under rollback / TAS replay.
1238    #[must_use]
1239    pub const fn frame(&self) -> u64 {
1240        self.bus.ppu().frame()
1241    }
1242
1243    /// Borrow the underlying bus (debugger / tests).
1244    #[must_use]
1245    pub const fn bus(&self) -> &LockstepBus {
1246        &self.bus
1247    }
1248
1249    /// Mutably borrow the underlying bus (debugger / tests).
1250    pub const fn bus_mut(&mut self) -> &mut LockstepBus {
1251        &mut self.bus
1252    }
1253
1254    /// Assert or release the external /NMI pin. **Co-simulation only.**
1255    ///
1256    /// v2.5.1, [ADR 0038]. Rung 2's interrupt sweep needs the same stimulus on
1257    /// both sides, and this emulator had no way to receive it: its /NMI comes
1258    /// from the PPU and its /IRQ from the APU frame counter or a mapper, none of
1259    /// which exist at the CPU rung of the co-simulation.
1260    ///
1261    /// `true` asserts the pin. It is OR'd into the CPU's existing poll and
1262    /// consumed on the edge exactly as a PPU-generated NMI is, so the CPU cannot
1263    /// tell the two apart -- which is the property that makes the sweep test the
1264    /// CPU rather than the injection. It does **not** bypass the poll, force a
1265    /// vector, or short-circuit the sequence.
1266    ///
1267    /// Gated behind `cosim-interrupt-inject`, which nothing in the workspace
1268    /// enables. A default build does not contain this method or its state.
1269    ///
1270    /// [ADR 0038]: https://github.com/doublegate/RustyNES/blob/main/docs/adr/0038-cosim-interrupt-injection-api.md
1271    #[cfg(feature = "cosim-interrupt-inject")]
1272    pub const fn inject_nmi(&mut self, asserted: bool) {
1273        self.bus.set_inject_nmi(asserted);
1274    }
1275
1276    /// Assert or release the external /IRQ pin. **Co-simulation only.**
1277    ///
1278    /// Level-sensitive, as the pin is: it is masked by `I` through the CPU's own
1279    /// logic, and a pulse shorter than a poll is missed. Holding it asserted
1280    /// across several cycles is how a real device drives it. See
1281    /// [`Self::inject_nmi`] for the contract and the ADR.
1282    #[cfg(feature = "cosim-interrupt-inject")]
1283    pub const fn inject_irq(&mut self, asserted: bool) {
1284        self.bus.set_inject_irq(asserted);
1285    }
1286
1287    /// Borrow the CPU (debugger / tests).
1288    #[must_use]
1289    pub const fn cpu(&self) -> &Cpu {
1290        &self.cpu
1291    }
1292
1293    /// Cumulative CPU cycle count.
1294    #[must_use]
1295    pub const fn cycle(&self) -> u64 {
1296        self.bus.cycle()
1297    }
1298
1299    /// `true` when the CPU has executed a JAM/KIL/STP and is halted.
1300    ///
1301    /// (v2.0.0 beta.5: the `VsDualSystem` soft-lockstep driver guards its
1302    /// per-instruction stepping — the pre-existing debugger
1303    /// [`Self::step_instruction`] — on this.)
1304    #[must_use]
1305    pub const fn is_jammed(&self) -> bool {
1306        self.cpu.is_jammed()
1307    }
1308
1309    /// Cartridge region (NTSC / PAL / Dendy / Multi). Drives wall-clock
1310    /// frame pacing in the frontend and clock dividers in the chip cores.
1311    #[must_use]
1312    pub const fn region(&self) -> Region {
1313        match self.bus.region() {
1314            rustynes_mappers::Region::Pal => Region::Pal,
1315            rustynes_mappers::Region::Dendy => Region::Dendy,
1316            // iNES 1.0 "Multi" cartridges are treated as NTSC for pacing
1317            // (matches the PPU / APU init in `LockstepBus::with_sample_rate`).
1318            _ => Region::Ntsc,
1319        }
1320    }
1321
1322    /// Length in bytes of the loaded cartridge's PRG-ROM (read-only metadata).
1323    ///
1324    /// Exposed for the Lua scripting `cart:prg_size()` query (and any other
1325    /// read-only consumer); does not touch deterministic state.
1326    #[must_use]
1327    pub const fn prg_rom_len(&self) -> usize {
1328        self.bus.prg_rom_len()
1329    }
1330
1331    /// Length in bytes of the loaded cartridge's CHR-ROM (0 when the board uses
1332    /// CHR-RAM). Read-only metadata; backs the Lua `cart:chr_size()` query.
1333    #[must_use]
1334    pub const fn chr_rom_len(&self) -> usize {
1335        self.bus.chr_rom_len()
1336    }
1337
1338    /// The loaded mapper's iNES / NES 2.0 mapper id (backs `cart:mapper_id()`).
1339    #[must_use]
1340    pub fn mapper_id(&self) -> u16 {
1341        self.bus.mapper_debug_info().mapper_id
1342    }
1343
1344    /// Wall-clock frame duration for this cartridge's region. The frontend
1345    /// uses this to pace emulator advance independently of monitor refresh
1346    /// rate — without it, `Fifo` present mode on a 144 Hz monitor would
1347    /// run the emulator 2.4× too fast.
1348    #[must_use]
1349    pub const fn frame_duration(&self) -> Duration {
1350        match self.region() {
1351            Region::Pal => FRAME_DURATION_PAL,
1352            Region::Dendy => FRAME_DURATION_DENDY,
1353            Region::Ntsc => FRAME_DURATION_NTSC,
1354        }
1355    }
1356
1357    /// Drain accumulated audio samples (host sample rate, normalized
1358    /// `[0.0, ~1.0]`).  Call once per frame from the frontend's audio thread
1359    /// or batch driver.
1360    pub fn drain_audio(&mut self) -> Vec<f32> {
1361        self.bus.drain_audio()
1362    }
1363
1364    /// Set the buttons currently held on player `port`. Ports 0/1 are the
1365    /// standard controllers (`$4016`/`$4017`); ports 2/3 are players 3/4 on
1366    /// the Four Score adapter (only polled when [`Self::set_four_score`] is
1367    /// on). The change takes effect on the next strobe edge.
1368    ///
1369    /// # Panics
1370    ///
1371    /// Panics if `port` is not in `0..=3`.
1372    pub const fn set_buttons(&mut self, port: usize, buttons: Buttons) {
1373        self.bus.set_buttons(port, buttons);
1374    }
1375
1376    /// Get the buttons currently held on player `port` (0/1 = `$4016`/`$4017`;
1377    /// 2/3 = Four Score players 3/4). Read-only; does not advance emulator
1378    /// state.
1379    ///
1380    /// Used by the TAS movie recorder (`crate::movie`) to capture the inputs
1381    /// applied before each [`Self::run_frame`]. (Movies record players 1 & 2;
1382    /// Four Score players 3/4 are not part of the `.rnm` stream.)
1383    ///
1384    /// # Panics
1385    ///
1386    /// Panics if `port` is not in `0..=3`.
1387    #[must_use]
1388    pub const fn buttons(&self, port: usize) -> Buttons {
1389        self.bus.controller(port).buttons()
1390    }
1391
1392    /// Enable/disable the Four Score 4-player adapter. Off by default; while
1393    /// off, controller reads are byte-identical to the standard two-pad
1394    /// behavior (the determinism contract and save-states are unaffected).
1395    /// When on, players 3/4 (ports 2/3) are multiplexed onto `$4016`/`$4017`
1396    /// across a 24-read serial sequence.
1397    pub const fn set_four_score(&mut self, enabled: bool) {
1398        self.bus.set_four_score(enabled);
1399    }
1400
1401    /// Whether the Four Score adapter is currently enabled.
1402    #[must_use]
1403    pub const fn four_score(&self) -> bool {
1404        self.bus.four_score()
1405    }
1406
1407    // --- Vs. System DIP switches + coin/service inputs ---
1408
1409    /// True when the running cart is Nintendo Vs. System arcade hardware
1410    /// (NES 2.0 console type = Vs. System). The RGB PPU + DIP/coin inputs only
1411    /// take effect on such carts.
1412    #[must_use]
1413    pub fn is_vs_system(&self) -> bool {
1414        self.bus.is_vs_system()
1415    }
1416
1417    /// True when the cart's header marks a Vs. `DualSystem` board (two CPUs /
1418    /// two PPUs; NES 2.0 byte-13 high nibble = Vs. hardware type 5/6).
1419    ///
1420    /// Detection only: this single-system core cannot boot a `DualSystem` title
1421    /// past its attract handshake, so the frontend uses this to surface a clear
1422    /// note. The two-CPU/two-PPU emulation is a documented v2.0 deferral
1423    /// (`docs/audit/vs-dualsystem-design-2026-06-11.md`).
1424    #[must_use]
1425    pub const fn is_vs_dual_system(&self) -> bool {
1426        self.bus.is_vs_dual_system()
1427    }
1428
1429    /// Set the Vs. System 8-bit DIP-switch bank (switch 1 = bit 0 .. switch 8 =
1430    /// bit 7). Read through the upper bits of `$4016`/`$4017`. No effect on
1431    /// non-Vs. carts; the standard controller read stays byte-identical.
1432    pub const fn set_vs_dip(&mut self, dip: u8) {
1433        self.bus.set_vs_dip(dip);
1434    }
1435
1436    /// Current Vs. System DIP-switch bank.
1437    #[must_use]
1438    pub const fn vs_dip(&self) -> u8 {
1439        self.bus.vs_dip()
1440    }
1441
1442    /// Override the Vs. System PPU type and re-apply the output palette.
1443    ///
1444    /// iNES-1.0 Vs. dumps default to the 2C03 palette (no NES 2.0 byte-13);
1445    /// the per-game database ([`crate::vs_db`]) supplies the correct
1446    /// 2C04-000x / 2C05 type, which the frontend applies through this setter.
1447    /// Affects only the colour LUT the PPU emits through, never game logic.
1448    /// No effect on non-Vs. carts.
1449    pub const fn set_vs_ppu_type(&mut self, t: rustynes_mappers::VsPpuType) {
1450        self.bus.set_vs_ppu_type(t);
1451    }
1452
1453    /// Latch a Vs. System coin insertion on the given acceptor (0 = #1, 1 = #2).
1454    /// Reads true for a real-hardware ~40-70 ms window; the frontend should
1455    /// clear it (see [`Self::clear_coin`]) after a few frames.
1456    pub const fn insert_coin(&mut self, acceptor: u8) {
1457        self.bus.insert_coin(acceptor);
1458    }
1459
1460    /// Clear all latched Vs. System coin-insert signals.
1461    pub const fn clear_coin(&mut self) {
1462        self.bus.clear_coin();
1463    }
1464
1465    /// Set / clear the Vs. System service button.
1466    pub const fn set_vs_service(&mut self, pressed: bool) {
1467        self.bus.set_vs_service(pressed);
1468    }
1469
1470    // --- Famicom Disk System disk control (Stage 2b) ---
1471
1472    /// Number of disk sides in the inserted FDS image. Returns 0 for cartridge
1473    /// builds (so a frontend can branch on "is this an FDS game?").
1474    #[must_use]
1475    pub fn disk_side_count(&self) -> usize {
1476        self.bus.disk_side_count()
1477    }
1478
1479    /// The currently inserted FDS disk side index, or `None` when ejected (or
1480    /// for a cartridge build). A game that prompts "insert side B" is asking the
1481    /// user to call [`Self::set_disk_side`].
1482    #[must_use]
1483    pub fn inserted_disk_side(&self) -> Option<usize> {
1484        self.bus.inserted_disk_side()
1485    }
1486
1487    /// Insert FDS side `i` (`Some(i)`) or eject the disk (`None`). Inserting
1488    /// resets the head and opens a short deterministic "not ready" window (the
1489    /// BIOS polls `$4032` and waits for ready); an out-of-range index is
1490    /// ignored. No-op on cartridge builds. This is how the user complies with a
1491    /// game's "insert side N" prompt.
1492    pub fn set_disk_side(&mut self, side: Option<usize>) {
1493        self.bus.set_disk_side(side);
1494    }
1495
1496    /// Start recording the diagnostic FDS read-stream trace (the `$4031` disk-byte
1497    /// stream + `$4025` control writes + side changes). Off by default and
1498    /// observation-only — it never affects emulation, so the determinism contract
1499    /// holds. Drain it with [`Self::take_fds_trace`]. No-op on cartridge builds.
1500    /// Used by the `fds_trace` diagnostic harness to debug disk-read / side-swap
1501    /// failures (e.g. the Kid Icarus side-B `ERR.07` stall).
1502    pub fn enable_fds_trace(&mut self) {
1503        self.bus.enable_fds_trace();
1504    }
1505
1506    /// Drain the accumulated FDS read-stream trace records. Empty for cartridge
1507    /// builds or when [`Self::enable_fds_trace`] was never called.
1508    #[must_use]
1509    pub fn take_fds_trace(&mut self) -> Vec<rustynes_mappers::FdsTraceRec> {
1510        self.bus.take_fds_trace()
1511    }
1512
1513    /// Re-serialize the (possibly-modified) FDS disk image to the headerless
1514    /// `.fds` byte layout so the host can write it to a side-car `.fds.sav`
1515    /// (keyed by [`Self::rom_sha256`]). Empty for cartridge builds.
1516    #[must_use]
1517    pub fn disk_image_bytes(&self) -> Vec<u8> {
1518        self.bus.disk_image_bytes()
1519    }
1520
1521    /// Whether the FDS disk image has unsaved writes since the last
1522    /// [`Self::clear_disk_dirty`]. A frontend checks this on quit / periodically
1523    /// to decide whether to persist the disk.
1524    #[must_use]
1525    pub fn disk_is_dirty(&self) -> bool {
1526        self.bus.disk_is_dirty()
1527    }
1528
1529    /// Clear the FDS disk dirty flag after persisting the image.
1530    pub fn clear_disk_dirty(&mut self) {
1531        self.bus.clear_disk_dirty();
1532    }
1533
1534    /// Mark the inserted FDS disk read-only (`true`) or writable (`false`,
1535    /// the default). Drives the `$4032` write-protect flag; a write-protected
1536    /// disk drops bytes in write mode without modifying the medium.
1537    pub fn set_disk_write_protected(&mut self, protected: bool) {
1538        self.bus.set_disk_write_protected(protected);
1539    }
1540
1541    /// Attach a non-standard overlay input device on `port` (0 = `$4016`, 1 =
1542    /// `$4017`). Pass `None` to unplug it and return the port to the standard
1543    /// controller / Four Score path (byte-identical reads). Devices are
1544    /// unplugged on power-cycle.
1545    ///
1546    /// # Panics
1547    ///
1548    /// Panics if `port` is not in `0..=1`.
1549    pub fn set_expansion_device(&mut self, port: usize, device: Option<InputDevice>) {
1550        self.bus.set_expansion_device(port, device);
1551    }
1552
1553    /// Borrow the overlay device attached to `port` (0 = `$4016`, 1 =
1554    /// `$4017`), if any.
1555    ///
1556    /// # Panics
1557    ///
1558    /// Panics if `port` is not in `0..=1`.
1559    #[must_use]
1560    pub const fn expansion_device(&self, port: usize) -> &Option<InputDevice> {
1561        self.bus.expansion_device(port)
1562    }
1563
1564    /// Attach an Arkanoid "Vaus" paddle on `port` (typically port 1 / `$4017`)
1565    /// and set its position + fire state. `position` is the raw 8-bit
1566    /// potentiometer value (`$00` far-left .. `$FF` far-right); `fire` is the
1567    /// single button. Convenience wrapper that attaches the device if absent
1568    /// then updates it.
1569    ///
1570    /// # Panics
1571    ///
1572    /// Panics if `port` is not in `0..=1`.
1573    pub fn set_paddle(&mut self, port: usize, position: u8, fire: bool) {
1574        if !matches!(self.bus.expansion_device(port), Some(InputDevice::Vaus(_))) {
1575            self.bus.set_expansion_device(
1576                port,
1577                Some(InputDevice::Vaus(crate::input_device::VausState::new())),
1578            );
1579        }
1580        self.bus.set_paddle(port, position, fire);
1581    }
1582
1583    /// Attach an NES Zapper light gun on `port` (typically port 1 / `$4017`)
1584    /// and set its aim point + trigger. `(x, y)` is the screen pixel the gun is
1585    /// aimed at (0..256, 0..240; out of range = off-screen); `trigger` is the
1586    /// trigger state. Convenience wrapper that attaches the device if absent
1587    /// then updates it.
1588    ///
1589    /// Light detection is sampled from the framebuffer at the end of each
1590    /// [`Self::run_frame`]; the determinism contract holds because the sample
1591    /// only runs when a Zapper is attached (the no-device path is unchanged).
1592    ///
1593    /// # Panics
1594    ///
1595    /// Panics if `port` is not in `0..=1`.
1596    pub fn set_zapper(&mut self, port: usize, x: u16, y: u16, trigger: bool) {
1597        if !matches!(
1598            self.bus.expansion_device(port),
1599            Some(InputDevice::Zapper(_))
1600        ) {
1601            self.bus.set_expansion_device(
1602                port,
1603                Some(InputDevice::Zapper(crate::input_device::ZapperState::new())),
1604            );
1605        }
1606        self.bus.set_zapper(port, x, y, trigger);
1607    }
1608
1609    /// A3 (v2.2.3): enable the **beam-relative** Zapper light model.
1610    ///
1611    /// **Default ON since v2.3.6** (was off in v2.2.3-v2.3.5). See
1612    /// [`crate::bus::LockstepBus::set_zapper_temporal_light`] for the model and
1613    /// for why it was promoted; in short, the light bit is a function of where
1614    /// the CRT beam is at the moment of the read (dark before the beam paints
1615    /// the aim row, lit for the ~19-26-scanline photodiode hold, dark after)
1616    /// instead of one answer for the whole frame — and the frame model made a
1617    /// *Duck Hunt* hit impossible.
1618    ///
1619    /// Pass `false` to restore the pre-v2.3.6 frame-granular behaviour.
1620    ///
1621    /// Deterministic either way: the temporal answer is a pure function of
1622    /// framebuffer + aim + current scanline and holds no extra state, so it
1623    /// adds nothing to serialize and cannot desync a save state or a netplay
1624    /// rollback.
1625    pub const fn set_zapper_temporal_light(&mut self, on: bool) {
1626        self.bus.set_zapper_temporal_light(on);
1627    }
1628
1629    /// Whether the beam-relative Zapper light model is enabled (A3).
1630    #[must_use]
1631    pub const fn zapper_temporal_light(&self) -> bool {
1632        self.bus.zapper_temporal_light()
1633    }
1634
1635    /// Drive the Famicom built-in **microphone** (read on `$4016` bit 2).
1636    ///
1637    /// The hardwired second Famicom controller carries a push-to-talk mic that
1638    /// games poll on `$4016.D2` (e.g. *Zelda*'s Pols Voice, *Kid Icarus*). Pass
1639    /// `pressed = true` while the frontend's mic key is held / an audio source
1640    /// crosses the loudness threshold. Additive and opt-in: `false` (the
1641    /// default) keeps the `$4016` read byte-identical to a stock NES.
1642    pub const fn set_microphone(&mut self, pressed: bool) {
1643        self.bus.set_microphone(pressed);
1644    }
1645
1646    /// Attach an NES Power Pad / Family Fun Fitness mat on `port` (typically
1647    /// port 1 / `$4017`) and set its live button mask (bit `i` = mat button
1648    /// `i+1`, 0..=11). Convenience wrapper that attaches the device if absent
1649    /// then updates it. Opt-in: the no-device path stays byte-identical.
1650    ///
1651    /// # Panics
1652    ///
1653    /// Panics if `port` is not in `0..=1`.
1654    pub fn set_power_pad(&mut self, port: usize, buttons: u16) {
1655        if !matches!(
1656            self.bus.expansion_device(port),
1657            Some(InputDevice::PowerPad(_))
1658        ) {
1659            self.bus.set_expansion_device(
1660                port,
1661                Some(InputDevice::PowerPad(
1662                    crate::input_device::PowerPadState::new(),
1663                )),
1664            );
1665        }
1666        self.bus.set_power_pad(port, buttons);
1667    }
1668
1669    /// v1.6.0 B3 — the latched standard-controller button bitmask for `port`
1670    /// (`0` = P1 / `$4016`, `1` = P2 / `$4017`; `2`/`3` are the Four Score
1671    /// players), in [`Buttons`](crate::Buttons) bit order (A = bit 0 .. Right =
1672    /// bit 7). Read-only and side-effect-free — it reads the latched state, not
1673    /// the shift register, so it never perturbs a controller poll. Exposed for
1674    /// the Lua `joypad.get` query.
1675    ///
1676    /// # Panics
1677    ///
1678    /// Panics if `port` is not in `0..=3`.
1679    #[must_use]
1680    pub fn controller_buttons(&self, port: usize) -> u8 {
1681        assert!(port <= 3, "controller port {port} out of range (0..=3)");
1682        self.bus.controller(port).buttons().bits()
1683    }
1684
1685    /// v1.2.0 Workstream D — attach a SNES-style serial mouse on `port` (0 =
1686    /// `$4016`, 1 = `$4017`) and set its movement / buttons / sensitivity.
1687    /// `(dx, dy)` are the signed per-frame deltas (clamped to +/-127 on latch);
1688    /// `sensitivity` is 0 (low) / 1 (medium) / 2 (high). Convenience wrapper
1689    /// that attaches the device if absent then updates it. Opt-in: the no-device
1690    /// path stays byte-identical.
1691    ///
1692    /// # Panics
1693    ///
1694    /// Panics if `port` is not in `0..=1`.
1695    pub fn set_snes_mouse(
1696        &mut self,
1697        port: usize,
1698        dx: i16,
1699        dy: i16,
1700        left: bool,
1701        right: bool,
1702        sensitivity: u8,
1703    ) {
1704        if !matches!(
1705            self.bus.expansion_device(port),
1706            Some(InputDevice::SnesMouse(_))
1707        ) {
1708            self.bus.set_expansion_device(
1709                port,
1710                Some(InputDevice::SnesMouse(
1711                    crate::input_device::SnesMouseState::new(),
1712                )),
1713            );
1714        }
1715        self.bus
1716            .set_snes_mouse(port, dx, dy, left, right, sensitivity);
1717    }
1718
1719    /// v1.2.0 Workstream D — attach a Famicom Family BASIC keyboard on `port`
1720    /// (typically port 1 / `$4017`) and set its pressed-key bitmap. `keys` is
1721    /// one byte per matrix row (`keys[row]` bits 0..=3 = column-half 0 keys,
1722    /// bits 4..=7 = column-half 1 keys); the frontend builds it from host keys.
1723    /// Convenience wrapper that attaches the device if absent then updates it.
1724    /// Opt-in: the no-device path stays byte-identical.
1725    ///
1726    /// # Panics
1727    ///
1728    /// Panics if `port` is not in `0..=1`.
1729    pub fn set_family_keyboard(&mut self, port: usize, keys: [u8; 9]) {
1730        if !matches!(
1731            self.bus.expansion_device(port),
1732            Some(InputDevice::FamilyKeyboard(_))
1733        ) {
1734            self.bus.set_expansion_device(
1735                port,
1736                Some(InputDevice::FamilyKeyboard(
1737                    crate::input_device::FamilyKeyboardState::new(),
1738                )),
1739            );
1740        }
1741        self.bus.set_family_keyboard(port, keys);
1742    }
1743
1744    /// v1.3.0 Workstream F1 — attach a Bandai **Family Trainer** mat on `port`
1745    /// and set its 12-button mask (bit `i` = mat button `i+1`). The Family
1746    /// Trainer is layout-equivalent to the Power Pad and reuses its scan; this
1747    /// attaches the [`InputDevice::FamilyTrainer`] variant (distinct from
1748    /// [`Self::set_power_pad`] so the selected device round-trips through a
1749    /// save-state). Opt-in: the no-device path stays byte-identical.
1750    ///
1751    /// # Panics
1752    ///
1753    /// Panics if `port` is not in `0..=1`.
1754    pub fn set_family_trainer(&mut self, port: usize, buttons: u16) {
1755        if !matches!(
1756            self.bus.expansion_device(port),
1757            Some(InputDevice::FamilyTrainer(_))
1758        ) {
1759            self.bus.set_expansion_device(
1760                port,
1761                Some(InputDevice::FamilyTrainer(
1762                    crate::input_device::PowerPadState::new(),
1763                )),
1764            );
1765        }
1766        self.bus.set_family_trainer(port, buttons);
1767    }
1768
1769    /// v1.3.0 Workstream F1 — attach a **Subor keyboard** on `port` and set its
1770    /// pressed-key bitmap (one byte per matrix row, like
1771    /// [`Self::set_family_keyboard`]). The Subor keyboard reuses the Family
1772    /// BASIC keyboard matrix scan; this attaches the
1773    /// [`InputDevice::SuborKeyboard`] variant. Opt-in: the no-device path stays
1774    /// byte-identical.
1775    ///
1776    /// # Panics
1777    ///
1778    /// Panics if `port` is not in `0..=1`.
1779    pub fn set_subor_keyboard(&mut self, port: usize, keys: [u8; 9]) {
1780        if !matches!(
1781            self.bus.expansion_device(port),
1782            Some(InputDevice::SuborKeyboard(_))
1783        ) {
1784            self.bus.set_expansion_device(
1785                port,
1786                Some(InputDevice::SuborKeyboard(
1787                    crate::input_device::FamilyKeyboardState::new(),
1788                )),
1789            );
1790        }
1791        self.bus.set_subor_keyboard(port, keys);
1792    }
1793
1794    /// v1.3.0 Workstream F1 — attach a **Konami Hyper Shot** on `port` and set
1795    /// its 4-button mask (bit 0 = P1 Run, 1 = P1 Jump, 2 = P2 Run, 3 = P2 Jump).
1796    /// Opt-in: the no-device path stays byte-identical.
1797    ///
1798    /// # Panics
1799    ///
1800    /// Panics if `port` is not in `0..=1`.
1801    pub fn set_konami_hyper_shot(&mut self, port: usize, buttons: u8) {
1802        if !matches!(
1803            self.bus.expansion_device(port),
1804            Some(InputDevice::KonamiHyperShot(_))
1805        ) {
1806            self.bus.set_expansion_device(
1807                port,
1808                Some(InputDevice::KonamiHyperShot(
1809                    crate::input_device::KonamiHyperShotState::new(),
1810                )),
1811            );
1812        }
1813        self.bus.set_konami_hyper_shot(port, buttons);
1814    }
1815
1816    /// v1.3.0 Workstream F1 — attach a **Bandai Hyper Shot** (Exciting Boxing
1817    /// punching bag) on `port` and set its 8-sensor mask (bits 0..=3 = the A=0
1818    /// group, bits 4..=7 = the A=1 group). Opt-in: the no-device path stays
1819    /// byte-identical.
1820    ///
1821    /// # Panics
1822    ///
1823    /// Panics if `port` is not in `0..=1`.
1824    pub fn set_bandai_hyper_shot(&mut self, port: usize, sensors: u8) {
1825        if !matches!(
1826            self.bus.expansion_device(port),
1827            Some(InputDevice::BandaiHyperShot(_))
1828        ) {
1829            self.bus.set_expansion_device(
1830                port,
1831                Some(InputDevice::BandaiHyperShot(
1832                    crate::input_device::BandaiHyperShotState::new(),
1833                )),
1834            );
1835        }
1836        self.bus.set_bandai_hyper_shot(port, sensors);
1837    }
1838
1839    /// v1.1.0 beta.1 (T-110-B4) — set (`Some`) or clear (`None`) a per-game
1840    /// **nametable mirroring override**, a load-time correction for ROMs whose
1841    /// iNES header carries the wrong mirroring flag (supplied by the frontend's
1842    /// game database). `None` (default) defers to the mapper — byte-identical,
1843    /// so the determinism / `AccuracyCoin` contract and the core test suites are
1844    /// unaffected (they never set it). Persisted in the save-state. Does not
1845    /// affect mappers with on-cart VRAM (4-screen).
1846    pub const fn set_mirroring_override(&mut self, m: Option<rustynes_mappers::Mirroring>) {
1847        self.bus.set_mirroring_override(m);
1848    }
1849
1850    /// Whether the loaded mapper's nametable mirroring is **hardwired** by the
1851    /// cartridge (solder pads / header bit) rather than controlled by the
1852    /// mapper's own registers at runtime.
1853    ///
1854    /// The frontend consults this before honoring a game-database mirroring
1855    /// correction: a static override is only valid for a hardwired board, and
1856    /// force-applying one to a mapper that switches mirroring itself (MMC1/3/5,
1857    /// `AxROM`, VRC, …) corrupts its rendering. See
1858    /// [`rustynes_mappers::Mapper::has_hardwired_mirroring`].
1859    #[must_use]
1860    pub fn mapper_has_hardwired_mirroring(&self) -> bool {
1861        self.bus.mapper_has_hardwired_mirroring()
1862    }
1863
1864    /// Write a byte directly into CPU work RAM (`$0000-$1FFF`). Used by the
1865    /// frontend's raw RAM cheats (GameShark-style); applied *after*
1866    /// [`Self::run_frame`], so the deterministic core run loop is unchanged
1867    /// (the determinism contract holds for the no-cheat path). No-op outside
1868    /// system RAM.
1869    pub fn poke_ram(&mut self, addr: u16, value: u8) {
1870        self.bus.poke_ram(addr, value);
1871    }
1872
1873    /// v1.7.0 "Forge" Workstream A1 — debugger writeback into the PPU bus
1874    /// (`$0000-$3FFF`): CHR pattern bytes (mapper `ppu_write`, a no-op on
1875    /// CHR-ROM), nametable tiles/attributes (mapper-absorbed, else CIRAM via the
1876    /// active mirroring), and palette RAM. The PPU-bus counterpart of
1877    /// [`Self::poke_ram`].
1878    ///
1879    /// Reached only through the frontend's gated post-frame poke path (the same
1880    /// caller-side, after-[`Self::run_frame`] stage the raw RAM cheats use), so
1881    /// the deterministic core run loop is unchanged and the no-edit path is
1882    /// byte-identical. `debug-hooks`-gated.
1883    #[cfg(feature = "debug-hooks")]
1884    pub fn debug_poke_ppu(&mut self, addr: u16, value: u8) {
1885        self.bus.debug_poke_ppu(addr, value);
1886    }
1887
1888    /// v1.7.0 "Forge" Workstream A1 — debugger writeback for one OAM byte
1889    /// (`idx` = 0..256: byte 0 = Y, 1 = tile, 2 = attributes, 3 = X per
1890    /// sprite). `debug-hooks`-gated; reached only through the gated post-frame
1891    /// poke path, so the default build is byte-identical.
1892    #[cfg(feature = "debug-hooks")]
1893    pub const fn poke_oam_byte(&mut self, idx: u8, value: u8) {
1894        self.bus.debug_poke_oam(idx, value);
1895    }
1896
1897    /// v1.7.0 "Forge" Workstream B (Lua API parity) — debugger/scripted
1898    /// writeback of the CPU register file (`a`/`x`/`y`/`s`/`p` bits/`pc`). The
1899    /// structured-state counterpart of [`Self::poke_ram`], backing the Lua
1900    /// `emu:setState(t)` field map (Mesen2 parity).
1901    ///
1902    /// Reached only through the frontend / script crate's gated post-frame poke
1903    /// path (the same caller-side, after-[`Self::run_frame`] stage the raw RAM
1904    /// cheats + the other `debug_poke_*` writebacks use), so the deterministic
1905    /// core run loop is unchanged and the no-edit path is byte-identical.
1906    /// `debug-hooks`-gated. `p` is taken as a raw status-bits byte (truncated to
1907    /// the defined flags, mirroring a `PLP` / save-state restore).
1908    #[cfg(feature = "debug-hooks")]
1909    // This is a runtime register-file mutator (the structured-state counterpart
1910    // of `poke_ram`); `const` adds no value and would needlessly constrain the
1911    // body, so the `missing_const_for_fn` suggestion is declined here.
1912    #[allow(clippy::missing_const_for_fn)]
1913    pub fn debug_set_cpu_state(&mut self, a: u8, x: u8, y: u8, s: u8, p_bits: u8, pc: u16) {
1914        self.cpu.a = a;
1915        self.cpu.x = x;
1916        self.cpu.y = y;
1917        self.cpu.s = s;
1918        self.cpu.p = rustynes_cpu::Status::from_bits_truncate(p_bits);
1919        self.cpu.pc = pc;
1920    }
1921
1922    /// Read a byte from the CPU address space (`$0000-$FFFF`) for inspection,
1923    /// **without** the register side effects of a real CPU read — reading
1924    /// `$2002` does not clear the VBL flag / address latch and `$2007` does not
1925    /// advance the PPU read buffer. Used by the debugger and the Lua scripting
1926    /// API (`emu.read`); it observes state without advancing the emulator,
1927    /// preserving determinism.
1928    #[must_use]
1929    pub fn peek(&mut self, addr: u16) -> u8 {
1930        self.bus.debug_peek_cpu(addr)
1931    }
1932
1933    /// v1.2.0 C3 (hd-pack) — side-effect-free read of the PPU bus
1934    /// (`$0000-$3FFF`): CHR pattern data, nametables, palette RAM. Used by the
1935    /// HD-pack compositor to hash a tile's 16 CHR bytes. Observes state without
1936    /// advancing the emulator, preserving determinism.
1937    #[cfg(feature = "hd-pack")]
1938    #[must_use]
1939    pub fn peek_ppu(&mut self, addr: u16) -> u8 {
1940        self.bus.debug_peek_ppu(addr)
1941    }
1942
1943    /// Add a Game Genie code (6 or 8 characters, case-insensitive) that
1944    /// substitutes a byte the CPU reads from PRG-ROM (`$8000-$FFFF`).
1945    ///
1946    /// Codes are a runtime overlay — they are **not** part of the save-state
1947    /// and do not perturb the determinism contract when none are active. With
1948    /// codes active, the substituted bytes are part of the deterministic
1949    /// input (record a movie with the same codes to reproduce a run).
1950    ///
1951    /// # Errors
1952    ///
1953    /// Returns [`GenieError`] if the code string cannot be decoded.
1954    pub fn add_genie_code(&mut self, code: &str) -> Result<(), GenieError> {
1955        self.bus.add_genie_code(code)
1956    }
1957
1958    /// Remove the active Game Genie code whose canonical (upper-case) string
1959    /// matches `code`. No-op if no such code is active.
1960    pub fn remove_genie_code(&mut self, code: &str) {
1961        self.bus.remove_genie_code(code);
1962    }
1963
1964    /// Remove all active Game Genie codes.
1965    pub fn clear_genie_codes(&mut self) {
1966        self.bus.clear_genie_codes();
1967    }
1968
1969    /// Iterate the active Game Genie codes (address-sorted).
1970    pub fn genie_codes(&self) -> impl Iterator<Item = &GenieCode> {
1971        self.bus.genie_codes()
1972    }
1973
1974    /// Drain into a slice; returns the count copied.  Excess samples are
1975    /// dropped if `out` is smaller than the buffered count.
1976    pub fn drain_audio_into(&mut self, out: &mut [f32]) -> usize {
1977        self.bus.drain_audio_into(out)
1978    }
1979
1980    /// SHA-256 of the ROM bytes this emulator was constructed from.
1981    ///
1982    /// Used by the frontend's save-state file layout (one directory per
1983    /// ROM, keyed by hex-encoded SHA-256). The hash is computed once at
1984    /// `from_rom` time; subsequent calls are O(1).
1985    #[must_use]
1986    pub const fn rom_sha256(&self) -> &[u8; 32] {
1987        &self.rom_sha256
1988    }
1989
1990    /// Truncated ROM hash tag stored in the save-state header.
1991    #[must_use]
1992    pub fn rom_hash_tag(&self) -> [u8; ROM_HASH_TAG_LEN] {
1993        let mut t = [0u8; ROM_HASH_TAG_LEN];
1994        t.copy_from_slice(&self.rom_sha256[..ROM_HASH_TAG_LEN]);
1995        t
1996    }
1997
1998    /// Encode the entire emulator state into a `.rns` snapshot blob.
1999    ///
2000    /// Includes a versioned container header and the four chip + bus
2001    /// sections (`CPU `, `PPU `, `APU `, `MAP `, `BUS `), plus an optional
2002    /// `THM ` thumbnail section (128x120 RGBA8 nearest-neighbor downsample
2003    /// of the current framebuffer). The thumbnail is for UI slot pickers
2004    /// only -- per ADR 0003 it is NOT part of the deterministic save-state
2005    /// contract.
2006    #[must_use]
2007    pub fn snapshot(&self) -> Vec<u8> {
2008        let tag = self.rom_hash_tag();
2009        // The bus knows how to emit BUS / PPU / APU / MAP sections; we
2010        // splice the CPU section in at the end.
2011        let mut out = self.bus.snapshot(tag);
2012        let cpu_body = self.cpu.snapshot();
2013        save_state::write_section(
2014            &mut out,
2015            save_state::tag::CPU,
2016            rustynes_cpu::CPU_SNAPSHOT_VERSION,
2017            &cpu_body,
2018        );
2019        // Optional thumbnail. Body layout: width(u16 le) + height(u16 le) +
2020        // length(u32 le) + raw RGBA8. The fixed THUMBNAIL_LEN is what we
2021        // emit but the body carries the dimensions explicitly so future
2022        // bumps (different thumbnail sizes) can be detected by the reader.
2023        let thumb = self.thumbnail();
2024        let mut body = Vec::with_capacity(2 + 2 + 4 + save_state::THUMBNAIL_LEN);
2025        body.extend_from_slice(
2026            &u16::try_from(save_state::THUMBNAIL_WIDTH)
2027                .unwrap()
2028                .to_le_bytes(),
2029        );
2030        body.extend_from_slice(
2031            &u16::try_from(save_state::THUMBNAIL_HEIGHT)
2032                .unwrap()
2033                .to_le_bytes(),
2034        );
2035        body.extend_from_slice(&u32::try_from(thumb.len()).unwrap().to_le_bytes());
2036        body.extend_from_slice(&thumb);
2037        save_state::write_section(
2038            &mut out,
2039            save_state::tag::THM,
2040            save_state::THUMBNAIL_VERSION,
2041            &body,
2042        );
2043        out
2044    }
2045
2046    /// v2.8.0 Phase 3 — [`Self::snapshot`] minus the `THM ` thumbnail
2047    /// section, encoded into a caller-owned reused buffer. The fast path
2048    /// for per-frame consumers (run-ahead, the netplay save-state ring):
2049    /// no allocation in steady state and no 61 KiB thumbnail build. The
2050    /// output parses with [`Self::restore`] / [`Self::restore_quiet`]
2051    /// exactly like a full snapshot (`THM ` is optional by format).
2052    pub fn snapshot_core_into(&self, out: &mut Vec<u8>) {
2053        let tag = self.rom_hash_tag();
2054        self.bus.snapshot_into(out, tag);
2055        let cpu_body = self.cpu.snapshot();
2056        save_state::write_section(
2057            out,
2058            save_state::tag::CPU,
2059            rustynes_cpu::CPU_SNAPSHOT_VERSION,
2060            &cpu_body,
2061        );
2062    }
2063
2064    /// v2.3.3 — [`Self::snapshot_core_into`] with the PPU encoded **slim**
2065    /// (no framebuffer), for the rewind ring.
2066    ///
2067    /// The ring snapshots on every frame inside the frame budget and then XORs
2068    /// and LZ4-compresses the result. The framebuffer is 245,760 of the ~250 KB
2069    /// and is the worst possible payload for that scheme, since it changes
2070    /// every frame: the XOR never zeroes and the delta never compresses.
2071    /// Measured, that made rewind roughly double the produce-interval p95 and
2072    /// it was the cause of a user-visible judder report — see
2073    /// `docs/performance.md` v2.3.3 F3/F4.
2074    ///
2075    /// A blob written here restores every field except the framebuffer, so the
2076    /// caller must regenerate the image; [`Self::rewind_step_back`] runs one
2077    /// frame to do exactly that.
2078    pub fn snapshot_core_into_slim(&self, out: &mut Vec<u8>) {
2079        let tag = self.rom_hash_tag();
2080        self.bus.snapshot_into_slim(out, tag);
2081        let cpu_body = self.cpu.snapshot();
2082        save_state::write_section(
2083            out,
2084            save_state::tag::CPU,
2085            rustynes_cpu::CPU_SNAPSHOT_VERSION,
2086            &cpu_body,
2087        );
2088    }
2089
2090    /// Generate a 128x120 RGBA8 thumbnail of the current framebuffer.
2091    ///
2092    /// Nearest-neighbor downsample (sample every 2nd pixel of every 2nd row).
2093    /// The 1/4-resolution result is small enough that storing it in slot
2094    /// files is cheap (61,440 bytes uncompressed, ~10-20 KiB after the
2095    /// LZ4 path the rewind ring uses if it is ever wired through there).
2096    ///
2097    /// Per ADR 0003: NOT part of the deterministic save-state contract.
2098    /// Different builds may produce different pixel-perfect framebuffers
2099    /// at the same cycle if post-pass filters change.
2100    #[must_use]
2101    pub fn thumbnail(&self) -> Vec<u8> {
2102        // Native NES framebuffer is 256x240 RGBA8 = 245,760 bytes. Source
2103        // stride is 256 * 4 = 1024 bytes.
2104        const SRC_W: usize = 256;
2105        let fb = self.bus.framebuffer();
2106        let mut out = Vec::with_capacity(save_state::THUMBNAIL_LEN);
2107        for ty in 0..save_state::THUMBNAIL_HEIGHT {
2108            let sy = ty * 2;
2109            for tx in 0..save_state::THUMBNAIL_WIDTH {
2110                let sx = tx * 2;
2111                let i = (sy * SRC_W + sx) * 4;
2112                // Source framebuffer is always at least 256*240*4 bytes
2113                // (allocated by Ppu::new), so this index is in-bounds.
2114                out.extend_from_slice(&fb[i..i + 4]);
2115            }
2116        }
2117        debug_assert_eq!(out.len(), save_state::THUMBNAIL_LEN);
2118        out
2119    }
2120
2121    /// Extract a thumbnail from an `.rns` save-state blob without restoring
2122    /// it. Used by frontends to populate slot pickers.
2123    ///
2124    /// Returns `Ok(None)` if the blob is well-formed but contains no
2125    /// thumbnail section (older v0.9.0 slot files).
2126    ///
2127    /// # Errors
2128    ///
2129    /// Returns [`SnapshotError`] when the container header is malformed.
2130    pub fn extract_thumbnail(data: &[u8]) -> Result<Option<Vec<u8>>, SnapshotError> {
2131        let (_h, body_off) = save_state::parse_header(data)?;
2132        for s in save_state::SectionIter::new(&data[body_off..]) {
2133            let s = s?;
2134            if s.tag == save_state::tag::THM {
2135                // Body: width(u16) + height(u16) + length(u32) + bytes.
2136                if s.body.len() < 8 {
2137                    continue;
2138                }
2139                let w = u16::from_le_bytes([s.body[0], s.body[1]]) as usize;
2140                let h = u16::from_le_bytes([s.body[2], s.body[3]]) as usize;
2141                let n = u32::from_le_bytes([s.body[4], s.body[5], s.body[6], s.body[7]]) as usize;
2142                // Sanity: dimensions match what we currently emit, and the
2143                // declared length matches the body suffix.
2144                if w != save_state::THUMBNAIL_WIDTH
2145                    || h != save_state::THUMBNAIL_HEIGHT
2146                    || n != save_state::THUMBNAIL_LEN
2147                    || s.body.len() < 8 + n
2148                {
2149                    continue;
2150                }
2151                return Ok(Some(s.body[8..8 + n].to_vec()));
2152            }
2153        }
2154        Ok(None)
2155    }
2156
2157    /// Apply a previously [`Self::snapshot`]ed blob.
2158    ///
2159    /// Loading from a different ROM is allowed (the embedded hash tag is
2160    /// only a sanity check), but the result is undefined unless the chip
2161    /// section bodies are appropriate for the running mapper.
2162    ///
2163    /// # Errors
2164    ///
2165    /// Returns [`SnapshotError`] for malformed inputs.
2166    pub fn restore(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
2167        self.restore_inner(data, true)
2168    }
2169
2170    /// Shared restore body; `clear_rewind` distinguishes user-driven loads
2171    /// ([`Self::restore`] — the ring is invalidated) from same-timeline
2172    /// machine restores ([`Self::restore_quiet`] — the ring stays).
2173    fn restore_inner(&mut self, data: &[u8], clear_rewind: bool) -> Result<(), SnapshotError> {
2174        // v2.4.0 item B — a timeline jump, but only when this is a LOUD restore.
2175        //
2176        // `clear_rewind` already draws exactly the distinction the counter needs,
2177        // so it is reused rather than duplicated: `true` means a user-driven load
2178        // that invalidates the rewind history, `false` means a same-timeline
2179        // machine-driven restore (run-ahead's per-frame rollback, netplay's
2180        // rollback-resimulate) where the history stays valid.
2181        //
2182        // A same-timeline restore must NOT bump. That is the same rule the
2183        // provenance stash follows from the other direction — every same-timeline
2184        // restore has to carry the state that lives outside the save state — and
2185        // getting it wrong here would clear a consumer's telemetry sixty times a
2186        // second under run-ahead, which is worse than the stale-telemetry defect
2187        // this counter exists to fix.
2188        //
2189        // Bumped BEFORE the restore can fail, deliberately. A partially-applied
2190        // restore is a discontinuity whether or not it completed, and a consumer
2191        // that keeps stale telemetry because the jump errored is the bug in its
2192        // most confusing form.
2193        if clear_rewind {
2194            self.timeline_generation = self.timeline_generation.wrapping_add(1);
2195        }
2196        // Restore bus first — it consumes BUS / PPU / APU / MAP sections.
2197        self.bus.restore(data)?;
2198        // Then walk the sections again to find the CPU body.
2199        let (_h, body_off) = save_state::parse_header(data)?;
2200        let mut saw_cpu = false;
2201        for s in save_state::SectionIter::new(&data[body_off..]) {
2202            let s = s?;
2203            if s.tag == save_state::tag::CPU {
2204                if s.version != rustynes_cpu::CPU_SNAPSHOT_VERSION {
2205                    return Err(SnapshotError::VersionMismatch {
2206                        tag: save_state::tag_string(s.tag),
2207                        file_version: s.version,
2208                        chip_supports: rustynes_cpu::CPU_SNAPSHOT_VERSION,
2209                    });
2210                }
2211                self.cpu
2212                    .restore(s.body)
2213                    .map_err(|e| SnapshotError::SectionInvalid {
2214                        tag: save_state::tag_string(s.tag),
2215                        reason: format!("{e}"),
2216                    })?;
2217                saw_cpu = true;
2218            }
2219        }
2220        if !saw_cpu {
2221            return Err(SnapshotError::MissingSection("CPU ".into()));
2222        }
2223        // Loading invalidates the rewind ring (the new state is unrelated
2224        // to what was buffered before).
2225        if clear_rewind && let Some(r) = &mut self.rewind {
2226            r.clear();
2227        }
2228        // v2.3.2 "Lucid" — and it invalidates write attribution for the same
2229        // reason, on BOTH restore paths. The restored bytes were not written by
2230        // any instruction this session executed, so the PCs recorded against
2231        // those offsets describe a timeline that no longer exists. Reporting
2232        // them would be a confidently wrong answer; reporting nothing until the
2233        // program writes again is the honest one.
2234        //
2235        // v2.3.6 CORRECTION. This comment used to end by claiming that under
2236        // run-ahead the clear "fires once per displayed frame, leaving exactly
2237        // the visible frame's writes — which is the timeline the user is looking
2238        // at". That was false about the two lines below it, which empty both
2239        // stores completely; and because run-ahead's rollback is the LAST thing
2240        // before the frontend releases the emulator lock, the wipe landed on the
2241        // visible frame's records before any UI could read them. The shipped
2242        // Pixel Provenance inspector therefore rendered an empty report for every
2243        // user with the default `run_ahead = 1`. The clear here is right and
2244        // stays; run-ahead now carries the stores AROUND it (`RunAhead::finish`
2245        // → `Nes::take_provenance` / `put_provenance`), which is what this
2246        // comment always claimed was happening.
2247        //
2248        // The per-pixel provenance frame is cleared for the same reason, and it
2249        // needs saying separately because the obvious analogy is wrong: the
2250        // framebuffer IS serialized and comes back consistent with the restored
2251        // state, while this frame is not. A restore landing mid-frame would
2252        // otherwise leave pre-restore tile and palette addresses for every pixel
2253        // above the current scanline, unmarked. (Review catch on PR #356.)
2254        #[cfg(feature = "debug-hooks")]
2255        {
2256            self.bus.ppu.clear_write_attribution();
2257            self.bus.ppu.clear_pixel_provenance();
2258            // v2.3.7 — the audio register attribution is the same kind of claim
2259            // about the same replaced timeline: a restored state's APU registers
2260            // were not written by any instruction this session executed, so
2261            // keeping their PCs would report a timeline that no longer exists.
2262            //
2263            // This was MISSING when audio provenance first landed, while
2264            // `docs/audio-provenance.md` already asserted that "save-state loads
2265            // and netplay rollback still clear" — prose describing behaviour the
2266            // code did not have, which is the exact failure that let Pixel
2267            // Provenance ship broken for four releases. Caught in review.
2268            //
2269            // Harmless for run-ahead: `RunAhead::finish` TAKES the store before
2270            // `restore_quiet` and puts it back after, so `audio_prov` is `None`
2271            // here and this call is a no-op on that path.
2272            self.bus.apu.clear_audio_provenance_history();
2273        }
2274        Ok(())
2275    }
2276
2277    /// v2.8.0 Phase 3 — [`Self::restore`] WITHOUT clearing the rewind ring.
2278    ///
2279    /// For internal, machine-driven restores on the same timeline —
2280    /// run-ahead's per-frame rollback and netplay's rollback-resimulate —
2281    /// where the buffered rewind history remains exactly as valid as
2282    /// before. User-driven loads (save-state slots) keep using
2283    /// [`Self::restore`], which invalidates the ring.
2284    ///
2285    /// # Errors
2286    ///
2287    /// Returns [`SnapshotError`] for malformed inputs.
2288    pub fn restore_quiet(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
2289        self.restore_inner(data, false)
2290    }
2291
2292    /// Enable the rewind ring buffer with default capacity (32 MiB) and
2293    /// keyframe period (60).
2294    pub fn enable_rewind(&mut self) {
2295        self.enable_rewind_with(REWIND_DEFAULT_MAX_BYTES, REWIND_DEFAULT_KEYFRAME_PERIOD);
2296    }
2297
2298    /// Enable rewind with explicit byte budget + keyframe period.
2299    pub fn enable_rewind_with(&mut self, max_bytes: usize, keyframe_period: u32) {
2300        self.rewind = Some(RewindRing::new(max_bytes, keyframe_period));
2301    }
2302
2303    /// Disable rewind and free the buffer.
2304    pub fn disable_rewind(&mut self) {
2305        self.rewind = None;
2306    }
2307
2308    /// Enable the per-CPU-instruction boot trace fixture with the given
2309    /// [`CpuBootTrace`](crate::cpu_boot_trace::CpuBootTrace).  Records past
2310    /// the trace's capacity are silently dropped (see
2311    /// [`CpuBootTrace::overflow`](crate::cpu_boot_trace::CpuBootTrace::overflow)).
2312    /// See `crates/rustynes-core/src/cpu_boot_trace.rs` for usage.
2313    #[cfg(feature = "cpu-boot-trace")]
2314    pub fn enable_cpu_boot_trace(&mut self, trace: crate::cpu_boot_trace::CpuBootTrace) {
2315        self.cpu_boot_trace = Some(trace);
2316    }
2317
2318    /// Take the accumulated CPU boot trace, leaving the slot empty.
2319    /// Returns `None` if tracing was never enabled.
2320    #[cfg(feature = "cpu-boot-trace")]
2321    #[must_use]
2322    pub const fn take_cpu_boot_trace(&mut self) -> Option<crate::cpu_boot_trace::CpuBootTrace> {
2323        self.cpu_boot_trace.take()
2324    }
2325
2326    /// Borrow the in-flight CPU boot trace for inspection.
2327    #[cfg(feature = "cpu-boot-trace")]
2328    #[must_use]
2329    pub const fn cpu_boot_trace(&self) -> Option<&crate::cpu_boot_trace::CpuBootTrace> {
2330        self.cpu_boot_trace.as_ref()
2331    }
2332
2333    /// Snapshot the current `(CPU register file + bus cycle + PPU
2334    /// position + opcode preview)` tuple into the CPU boot trace.
2335    ///
2336    /// Called from `run_frame` / `step_instruction` BEFORE the
2337    /// `Cpu::step` call.  The opcode + 2 operand bytes are peeked
2338    /// side-effect-free via `LockstepBus::debug_peek_cpu` so the
2339    /// trace is non-perturbing.
2340    ///
2341    /// No-op if the trace was never enabled.
2342    #[cfg(feature = "cpu-boot-trace")]
2343    fn cpu_boot_trace_record(&mut self) {
2344        use crate::cpu_boot_trace::CpuBootRecord;
2345        let Some(trace) = self.cpu_boot_trace.as_mut() else {
2346            return;
2347        };
2348        let cycle = self.bus.cycle();
2349        // Range pre-check: skip the peek bookkeeping entirely if this
2350        // cycle is outside the configured window.  The trace's own
2351        // `maybe_push` re-checks; the pre-check is the hot-path
2352        // optimisation.
2353        if !trace.config().contains(cycle) {
2354            return;
2355        }
2356        let pc = self.cpu.pc;
2357        let opcode = self.bus.debug_peek_cpu(pc);
2358        let op1 = self.bus.debug_peek_cpu(pc.wrapping_add(1));
2359        let op2 = self.bus.debug_peek_cpu(pc.wrapping_add(2));
2360        let ppu = self.bus.ppu();
2361        let mut flags: u8 = 0;
2362        // Mesen2 exposes `cpu.nmiFlag` and `cpu.irqFlag` (its
2363        // own pending-interrupt latches) but not the
2364        // armed-vs-pending distinction; flag bit 0 means "PPU is
2365        // driving NMI line high" which is observable on both
2366        // sides at instruction-fetch boundary.
2367        if ppu.nmi_line() {
2368            flags |= 0x01;
2369        }
2370        let rec = CpuBootRecord {
2371            cycle,
2372            frame: u32::try_from(ppu.frame()).unwrap_or(u32::MAX),
2373            scanline: ppu.scanline(),
2374            dot: ppu.dot(),
2375            pc,
2376            a: self.cpu.a,
2377            x: self.cpu.x,
2378            y: self.cpu.y,
2379            p: self.cpu.p.bits(),
2380            s: self.cpu.s,
2381            opcode,
2382            op1,
2383            op2,
2384            flags,
2385        };
2386        trace.maybe_push(rec);
2387    }
2388
2389    /// Push the current state onto the rewind ring. Frontends call this
2390    /// at the end of each completed frame.
2391    ///
2392    /// No-op if rewind is disabled.
2393    pub fn rewind_capture(&mut self) {
2394        if self.rewind.is_none() {
2395            return;
2396        }
2397        let frame = self.bus.ppu().frame();
2398        // v2.8.0 Phase 3 — the core fast path: no THM thumbnail (the ring
2399        // is never shown in a slot picker) and a reused buffer instead of
2400        // a fresh ~320 KiB allocation per frame. The ring still LZ4s /
2401        // delta-encodes the bytes itself.
2402        let mut buf = core::mem::take(&mut self.rewind_snap_buf);
2403        // v2.3.3 — SLIM: omit the framebuffer. See `snapshot_core_into_slim`.
2404        self.snapshot_core_into_slim(&mut buf);
2405        if let Some(ring) = &mut self.rewind {
2406            ring.push(frame, &buf);
2407        }
2408        self.rewind_snap_buf = buf;
2409    }
2410
2411    /// Pop the most recent rewind entry and restore it. Returns `true` on
2412    /// success, `false` if the ring is empty (or rewind is disabled).
2413    pub fn rewind_step_back(&mut self) -> bool {
2414        let Some(ring) = self.rewind.as_mut() else {
2415            return false;
2416        };
2417        let Some(result) = ring.pop_back() else {
2418            return false;
2419        };
2420        let bytes = match result {
2421            Ok(b) => b,
2422            Err(_e) => return false,
2423        };
2424        // Restore but keep the ring alive (don't let `restore` clear it,
2425        // because the user is mid-rewind).
2426        let saved_ring = self.rewind.take();
2427        let r = self.restore(&bytes);
2428        // Reattach the (possibly cleared, but cleared-by-us is fine) ring.
2429        self.rewind = saved_ring;
2430        if r.is_err() {
2431            return false;
2432        }
2433        // v2.3.3 — ring entries are SLIM, so the restore above left the
2434        // framebuffer holding whatever was last displayed; without an image the
2435        // picture would freeze while the state rewound. Regenerate it WITHOUT
2436        // changing the state this call is contracted to land on:
2437        //
2438        //   1. state is at frame N (restored above), framebuffer stale
2439        //   2. run one frame  -> renders exactly frame N's image, state N+1
2440        //   3. restore the same bytes -> state back to N
2441        //
2442        // Step 3 is what makes this exact rather than approximate, and it works
2443        // *because* the blob is slim: a slim restore does not touch the
2444        // framebuffer, so the image rendered in step 2 survives. The observable
2445        // contract is unchanged from before v2.3.3 — `cycle()` lands on the
2446        // snapshotted frame — which the `rewind_step_back_returns_prior_frames`
2447        // harness test pins.
2448        //
2449        // Capture is suppressed across step 2, or stepping back would push the
2450        // frame it just rendered and the ring would never drain.
2451        let saved_capture = self.rewind_capture_enabled;
2452        self.rewind_capture_enabled = false;
2453        self.run_frame();
2454        let saved_ring = self.rewind.take();
2455        let re = self.restore(&bytes);
2456        self.rewind = saved_ring;
2457        self.rewind_capture_enabled = saved_capture;
2458        re.is_ok()
2459    }
2460
2461    /// Drop every buffered rewind entry. Called when the user releases
2462    /// the rewind key, so subsequent forward play overwrites — there's
2463    /// nothing to overwrite, but we want forward play to capture into a
2464    /// fresh ring rather than tail-of-old-history.
2465    pub fn rewind_clear(&mut self) {
2466        if let Some(r) = &mut self.rewind {
2467            r.clear();
2468        }
2469    }
2470
2471    /// `true` if rewind is enabled.
2472    #[must_use]
2473    pub const fn rewind_enabled(&self) -> bool {
2474        self.rewind.is_some()
2475    }
2476
2477    /// Number of buffered rewind entries.
2478    #[must_use]
2479    pub fn rewind_len(&self) -> usize {
2480        self.rewind.as_ref().map_or(0, RewindRing::len)
2481    }
2482
2483    /// Approximate memory used by the rewind ring, in bytes.
2484    #[must_use]
2485    pub fn rewind_bytes_used(&self) -> usize {
2486        self.rewind.as_ref().map_or(0, RewindRing::bytes_used)
2487    }
2488
2489    // -------------------------------------------------------------------
2490    // Debugger inspection API (Sprint 5-3). All read-only — these methods
2491    // MUST NOT advance emulator-visible state.
2492    // -------------------------------------------------------------------
2493
2494    /// Snapshot the CPU register file.
2495    #[must_use]
2496    #[allow(clippy::missing_const_for_fn)] // `is_jammed` is const-callable but we're const-conservative.
2497    pub fn cpu_snapshot(&self) -> CpuDebugView {
2498        let c = &self.cpu;
2499        CpuDebugView {
2500            a: c.a,
2501            x: c.x,
2502            y: c.y,
2503            s: c.s,
2504            pc: c.pc,
2505            p: c.p.bits(),
2506            jammed: c.is_jammed(),
2507            cycles: c.cycles,
2508        }
2509    }
2510
2511    /// Snapshot PPU state for the debugger.
2512    #[must_use]
2513    #[allow(clippy::missing_const_for_fn)]
2514    pub fn ppu_snapshot(&self) -> PpuDebugView {
2515        let ppu = self.bus.ppu();
2516        let regs = ppu.debug_registers();
2517        let (v, t, fine_x, w) = ppu.debug_scroll();
2518        PpuDebugView {
2519            dot: ppu.dot(),
2520            scanline: ppu.scanline(),
2521            frame: ppu.frame(),
2522            ctrl: regs[0],
2523            mask: regs[1],
2524            status: regs[2],
2525            oam_addr: regs[3],
2526            v,
2527            t,
2528            fine_x,
2529            w_toggle: w,
2530            sprite_size_16: ppu.sprite_size_16(),
2531            bg_pattern_base: ppu.bg_pattern_base(),
2532            sprite_pattern_base: ppu.sprite_pattern_base(),
2533            nmi_line: ppu.nmi_line(),
2534        }
2535    }
2536
2537    /// Snapshot APU channel outputs and IRQ flags.
2538    #[must_use]
2539    pub fn apu_snapshot(&self) -> ApuDebugView {
2540        let apu = self.bus.apu();
2541        ApuDebugView {
2542            pulse1: apu.pulse1_out(),
2543            pulse2: apu.pulse2_out(),
2544            triangle: apu.triangle_out(),
2545            noise: apu.noise_out(),
2546            dmc: apu.dmc_out(),
2547            external: apu.external_out(),
2548            frame_irq: apu.frame_irq_pending(),
2549            dmc_irq: apu.dmc_irq_pending(),
2550        }
2551    }
2552
2553    /// Set the APU per-channel enable mask (a UI playback overlay, NOT NES
2554    /// hardware state). Bit 0 = pulse 1, 1 = pulse 2, 2 = triangle, 3 = noise,
2555    /// 4 = DMC, 5 = external/mapper audio. A cleared bit mutes that channel.
2556    ///
2557    /// The default ([`rustynes_apu::CHANNEL_MASK_ALL`]) is byte-identical to
2558    /// the un-masked mixer — the deterministic per-frame audio is unchanged
2559    /// unless the frontend explicitly mutes a channel. This is never written
2560    /// into the save state, so it never affects determinism or round-trips.
2561    pub const fn set_apu_channel_mask(&mut self, mask: u8) {
2562        self.bus.apu_mut().set_channel_mask(mask);
2563    }
2564
2565    /// Current APU per-channel enable mask. See [`Self::set_apu_channel_mask`].
2566    #[must_use]
2567    pub const fn apu_channel_mask(&self) -> u8 {
2568        self.bus.apu().channel_mask()
2569    }
2570
2571    /// v1.4.0 Workstream C — set the APU per-channel output gain (a UI mixing
2572    /// overlay, NOT NES hardware state), generalizing [`Self::set_apu_channel_mask`].
2573    /// Index 0 = pulse 1, 1 = pulse 2, 2 = triangle, 3 = noise, 4 = DMC,
2574    /// 5 = external/mapper audio. Each gain is clamped to `0.0..=2.0`.
2575    ///
2576    /// The default ([`rustynes_apu::CHANNEL_GAIN_UNITY`], all `1.0`) is
2577    /// byte-identical to the un-scaled mixer — the deterministic per-frame audio
2578    /// is unchanged unless the frontend explicitly changes a gain. Never written
2579    /// into the save state, so it never affects determinism or round-trips.
2580    pub fn set_apu_channel_gain(&mut self, gain: [f32; 6]) {
2581        self.bus.apu_mut().set_channel_gain(gain);
2582    }
2583
2584    /// v2.1.3 — select the APU analog output-filter model (see
2585    /// [`rustynes_apu::FilterModel`]). The default
2586    /// [`rustynes_apu::FilterModel::NesRf`] (NES front-loader: 90 + 440 Hz HPF +
2587    /// 14 kHz LPF) is byte-identical to the pre-v2.1.3 output; `Famicom` (37 Hz
2588    /// HPF) and `Clean` (~10 Hz DC-block) drop the aggressive 440 Hz high-pass
2589    /// for a fuller low end. Tonal only — channel content is unchanged, and the
2590    /// model is never written into the save state (a frontend/config concern,
2591    /// re-applied at load), so determinism and round-trips are unaffected.
2592    pub fn set_apu_filter_model(&mut self, model: rustynes_apu::FilterModel) {
2593        self.bus.apu_mut().set_filter_model(model);
2594    }
2595
2596    /// Current APU per-channel output gain. See [`Self::set_apu_channel_gain`].
2597    #[must_use]
2598    pub const fn apu_channel_gain(&self) -> [f32; 6] {
2599        self.bus.apu().channel_gain()
2600    }
2601
2602    /// Borrow OAM (256 bytes = 64 sprites x 4 bytes).
2603    ///
2604    /// Returns a cloned `[u8; 256]` so the caller doesn't have to manage
2605    /// a borrow lifetime against `&self`.
2606    #[must_use]
2607    pub fn oam(&self) -> [u8; 256] {
2608        let mut out = [0u8; 256];
2609        let oam = self.bus.ppu().oam();
2610        out.copy_from_slice(&oam[..256]);
2611        out
2612    }
2613
2614    /// One OAM byte (`index` = `0..=255`), without copying the whole 256-byte
2615    /// array — for single-byte readers (e.g. the Lua `memory:read_oam`) that
2616    /// would otherwise pay a full `oam()` copy per access. Read-only.
2617    #[must_use]
2618    pub fn oam_byte(&self, index: u8) -> u8 {
2619        self.bus.ppu().oam()[index as usize]
2620    }
2621
2622    /// Borrow palette RAM (32 bytes).
2623    #[must_use]
2624    pub const fn palette_ram(&self) -> [u8; 32] {
2625        *self.bus.ppu().palette_ram()
2626    }
2627
2628    /// v1.1.0 beta.1 — install (`Some`) or clear (`None`) a custom 64-entry base
2629    /// palette loaded from a `.pal` file. A frontend presentation override: it
2630    /// re-tints the displayed RGBA framebuffer via the PPU's colour LUT but does
2631    /// not touch any logical core state. `None` (the default) is byte-identical to
2632    /// the built-in palette, so `AccuracyCoin` + the commercial oracle (which never
2633    /// set one) are unaffected. Not part of the save-state.
2634    pub const fn set_custom_palette(&mut self, base: Option<[[u8; 3]; 64]>) {
2635        self.bus.set_custom_palette(base);
2636    }
2637
2638    /// v1.7.0 "Forge" Workstream F3 — set the PPU extra-scanlines overclock: the
2639    /// number of EXTRA idle vblank scanlines the PPU inserts per frame (at the
2640    /// existing dot resolution, Mesen2 `UpdateTimings`). Each extra line is pure
2641    /// additional CPU run-time — it renders nothing, sets/clears no PPU flag, and
2642    /// fires no VBL/NMI/A12 event, so the visible image is unchanged. `0` (the
2643    /// default) is **byte-identical** to stock NES timing — `AccuracyCoin`, the
2644    /// commercial oracle, and nestest (which never set it) are unaffected.
2645    /// **Off by default**; a frontend config knob, not part of the save-state.
2646    /// Distinct from the CPU-multiplier overclock (a v2.0 timebase item).
2647    pub const fn set_extra_scanlines(&mut self, lines: u16) {
2648        self.bus.set_extra_scanlines(lines);
2649    }
2650
2651    /// v1.7.0 F3 — the configured extra-scanline overclock count (`0` = stock).
2652    #[must_use]
2653    pub const fn extra_scanlines(&self) -> u16 {
2654        self.bus.extra_scanlines()
2655    }
2656
2657    /// v2.1.8 A1 — enable or disable the specialized visible-scanline fast dot
2658    /// path (a pure performance optimization for the PPU's hottest per-dot
2659    /// case).
2660    ///
2661    /// The PPU dot FSM (`Ppu::tick`) is the emulator's single hottest function
2662    /// (~46% of a representative frame's self-time). This knob dispatches the
2663    /// common "clean" visible BG-render dots (visible scanline, dots `1..=256`,
2664    /// rendering stably enabled, no sub-dot disturbance) to a straight-line
2665    /// handler that runs the identical helper sequence with the statically-dead
2666    /// event branches pruned. **On by default since v2.2.3** (OFF through
2667    /// v2.2.2) and **byte-identical** to the exact path — proven bit-for-bit
2668    /// every frame by the differential test (`fast_dotloop_diff`) and the full
2669    /// `AccuracyCoin` / visual-regression / nestest oracle, and measured at
2670    /// **-11.3%** on the rendering-heavy `full_frame` bench. Setting it `false`
2671    /// selects the fully-general per-dot path and remains the fallback. A
2672    /// frontend/config knob, NOT part of the save-state.
2673    pub const fn set_fast_dotloop(&mut self, enabled: bool) {
2674        self.bus.set_fast_dotloop(enabled);
2675    }
2676
2677    /// v2.1.8 A1 — whether the visible-scanline fast dot path is enabled
2678    /// (`true` = default since v2.2.3; both settings produce identical frames).
2679    #[must_use]
2680    pub const fn fast_dotloop(&self) -> bool {
2681        self.bus.fast_dotloop()
2682    }
2683
2684    /// v2.1.4 F2.3 — enable or disable the optional OAM-decay accuracy model.
2685    ///
2686    /// The 2C02's OAM is dynamic RAM refreshed by sprite evaluation; with rendering
2687    /// disabled for a while its un-refreshed 8-byte rows decay to a fixed garbage
2688    /// pattern. This models that (à la Mesen2's `EnableOamDecay`): a row un-touched
2689    /// for > 3000 CPU cycles decays on the next read. **Off by default** and
2690    /// **byte-identical** to a decay-free core when off — `AccuracyCoin`, the
2691    /// commercial oracle, and the visual regression suites are unaffected. It is
2692    /// NTSC/Dendy-only (PAL's refresh cadence masks decay). Deterministic when on
2693    /// (driven off the PPU's monotonic dot counter — no wall-clock / OS RNG), and a
2694    /// frontend/config knob re-applied on load, NOT part of the save-state (the
2695    /// in-flight per-row ages are serialized as a relative age so a rollback stays
2696    /// deterministic; the enable flag is not).
2697    pub const fn set_oam_decay(&mut self, enabled: bool) {
2698        self.bus.set_oam_decay(enabled);
2699    }
2700
2701    /// v2.1.4 F2.3 — whether the optional OAM-decay model is enabled (`false` =
2702    /// default, byte-identical to a decay-free core).
2703    #[must_use]
2704    pub const fn oam_decay_enabled(&self) -> bool {
2705        self.bus.oam_decay_enabled()
2706    }
2707
2708    /// v2.1.7 P5 — select the emulated 2C02 die revision (see [`PpuRevision`]).
2709    ///
2710    /// The [`PpuRevision::default`] ([`PpuRevision::Rp2c02H`]) models no extra
2711    /// quirks, so at the default this is inert and the core is **byte-identical**
2712    /// to a build without it — `AccuracyCoin`, the commercial oracle, and the
2713    /// visual / audio regression suites are unaffected. Selecting
2714    /// [`PpuRevision::Rp2c02G`] additionally models the OAMADDR (`$2003`)
2715    /// write-during-rendering OAM corruption glitch (*Huge Insect*). The
2716    /// selection is stored so a power-cycle re-applies it; it is config, not
2717    /// save-state (the corruption *state* it can arm already round-trips via the
2718    /// v6 PPU snapshot tail). Deterministic. A frontend/config knob re-applied on
2719    /// load, mirroring [`Nes::set_oam_decay`].
2720    pub const fn set_ppu_revision(&mut self, revision: PpuRevision) {
2721        self.bus.set_ppu_revision(revision);
2722    }
2723
2724    /// v2.1.7 P5 — the currently-selected 2C02 die revision (default
2725    /// [`PpuRevision::Rp2c02H`], byte-identical).
2726    #[must_use]
2727    pub const fn ppu_revision(&self) -> PpuRevision {
2728        self.bus.ppu_revision()
2729    }
2730
2731    /// v2.1.7 P5 — apply a power-up palette-RAM pattern (see [`PaletteInit`]).
2732    ///
2733    /// The 2C02's palette RAM is not cleared at power-on; this selects the
2734    /// power-up contents. [`PaletteInit::default`] ([`PaletteInit::Zeroed`])
2735    /// keeps the established all-zero power-up palette, so at the default this is
2736    /// **byte-identical**. [`PaletteInit::Blargg`] loads the canonical blargg
2737    /// power-up dump for software that samples uninitialized palette RAM. Writes
2738    /// only palette RAM (already part of the snapshot), so no snapshot change is
2739    /// needed; the selection is stored so a power-cycle re-applies it. Best
2740    /// called at power-on (palette RAM is preserved across a warm reset, like
2741    /// real hardware).
2742    pub const fn set_power_up_palette(&mut self, init: PaletteInit) {
2743        self.bus.set_power_up_palette(init);
2744    }
2745
2746    /// v2.1.7 P5 — the currently-selected power-up palette pattern (default
2747    /// [`PaletteInit::Zeroed`], byte-identical).
2748    #[must_use]
2749    pub const fn power_up_palette(&self) -> PaletteInit {
2750        self.bus.power_up_palette()
2751    }
2752
2753    /// v2.1.7 P5 — select the power-on work-RAM fill (see [`PowerOnRam`]).
2754    ///
2755    /// Applies the fill to the current 2 KiB work RAM (and open-bus latch)
2756    /// immediately and stores it so a power-cycle re-applies the same fill
2757    /// (`power_cycle == fresh boot`). [`PowerOnRam::default`]
2758    /// ([`PowerOnRam::Zeroed`]) is the established all-zero power-up state
2759    /// (**byte-identical**); the other variants are opt-in and deterministic,
2760    /// surfacing software that reads uninitialized RAM (*Final Fantasy* RNG,
2761    /// *River City Ransom*, *Cybernoid*). RAM is not consulted during reset, so
2762    /// applying it here is safe.
2763    pub fn set_power_on_ram(&mut self, ram: PowerOnRam) {
2764        self.bus.set_power_on_ram(ram);
2765    }
2766
2767    /// v2.1.7 P5 — the currently-selected power-on work-RAM fill (default
2768    /// [`PowerOnRam::Zeroed`], byte-identical).
2769    #[must_use]
2770    pub const fn power_on_ram(&self) -> PowerOnRam {
2771        self.bus.power_on_ram()
2772    }
2773
2774    /// v2.1.7 "Hardware Revisions & DMA Frontier" — select the emulated Ricoh
2775    /// 2A03 die revision, which gates the DMA unit's "unexpected DMA" extra
2776    /// halt-read on the DMC-halt-overlaps-OAM-halt cycle.
2777    ///
2778    /// **[`Cpu2A03Revision::Rp2A03G`] is the default** and is byte-identical to
2779    /// the core as it shipped before v2.1.7 (`AccuracyCoin` 141/141, nestest
2780    /// 0-diff, every committed DMA oracle ROM `Passed`).
2781    /// [`Cpu2A03Revision::Rp2A03H`] is a purely additive, opt-in accuracy knob
2782    /// that omits the extra read; its direction is an **unverified hypothesis**
2783    /// (no public reference emulator or test ROM models the 2A03 die-revision
2784    /// DMA difference — see the type docs and ADR 0033). It is deterministic and
2785    /// a config knob re-applied on load, NOT part of the save-state.
2786    pub const fn set_cpu_2a03_revision(&mut self, revision: Cpu2A03Revision) {
2787        self.bus.set_cpu_2a03_revision(revision);
2788    }
2789
2790    /// v2.1.7 — the configured 2A03 die revision (default
2791    /// [`Cpu2A03Revision::Rp2A03G`], byte-identical to the pre-v2.1.7 core).
2792    #[must_use]
2793    pub const fn cpu_2a03_revision(&self) -> Cpu2A03Revision {
2794        self.bus.cpu_2a03_revision()
2795    }
2796
2797    /// Mapper debug info (bank registers, IRQ counters, mirroring, ...).
2798    #[must_use]
2799    pub fn mapper_info(&self) -> MapperDebugView {
2800        self.bus.mapper_debug_info()
2801    }
2802
2803    /// v1.4.0 Workstream C — the loaded mapper's on-cart expansion-audio chip
2804    /// name (e.g. `"VRC6"`, `"VRC7 (OPLL)"`, `"MMC5"`, `"Namco 163"`,
2805    /// `"Sunsoft 5B"`, `"FDS"`), or `None` when the board has no expansion audio
2806    /// (or the `mapper-audio` feature is compiled out). Used by the frontend to
2807    /// show the expansion-channel volume slider only when present, with a label.
2808    ///
2809    /// Discovery is dynamic: it consults the cached [`rustynes_mappers::MapperCaps`]
2810    /// `audio` flag (true only when the mapper overrides `mix_audio` with the
2811    /// feature on) and the mapper id to name the chip family.
2812    #[must_use]
2813    pub fn expansion_audio_chip(&self) -> Option<&'static str> {
2814        if !self.bus.mapper_caps().audio {
2815            return None;
2816        }
2817        let id = self.bus.mapper_debug_info().mapper_id;
2818        Some(match id {
2819            5 => "MMC5",
2820            19 | 210 => "Namco 163",
2821            20 => "FDS",
2822            24 | 26 => "VRC6",
2823            69 => "Sunsoft 5B",
2824            85 => "VRC7 (OPLL)",
2825            // The board overrides `mix_audio` but isn't one of the named
2826            // families above — surface a generic label so the slider still
2827            // appears (e.g. a future expansion-audio mapper).
2828            _ => "Expansion audio",
2829        })
2830    }
2831
2832    /// Side-effect-free CPU bus peek (for the hex viewer).
2833    pub fn cpu_bus_peek(&mut self, addr: u16) -> u8 {
2834        self.bus.debug_peek_cpu(addr)
2835    }
2836
2837    /// Side-effect-free PPU bus peek (for the hex viewer + visualizers).
2838    pub fn ppu_bus_peek(&mut self, addr: u16) -> u8 {
2839        self.bus.debug_peek_ppu(addr)
2840    }
2841
2842    /// Render the 256 tiles of a CHR pattern table as RGBA8 (128x128).
2843    ///
2844    /// `table` selects which of the two pattern tables: 0 -> `$0000`,
2845    /// 1 -> `$1000`. Uses BG palette 0 ($3F00-$3F03) for grayscale-ish
2846    /// rendering. ~80 KiB cloned; only call when the PPU pattern viewer
2847    /// is open.
2848    pub fn pattern_table_rgba(&mut self, table: u8) -> Vec<u8> {
2849        const TILE_W: usize = 8;
2850        const SHEET_W: usize = 128;
2851        const SHEET_H: usize = 128;
2852        let base: u16 = if table & 1 == 0 { 0 } else { 0x1000 };
2853        let mut out = vec![0u8; SHEET_W * SHEET_H * 4];
2854        for tile_y in 0..16u16 {
2855            for tile_x in 0..16u16 {
2856                let tile_index = tile_y * 16 + tile_x;
2857                for row in 0..8u16 {
2858                    let lo = self.ppu_bus_peek(base + tile_index * 16 + row);
2859                    let hi = self.ppu_bus_peek(base + tile_index * 16 + row + 8);
2860                    for col in 0..8u16 {
2861                        let bit = 7 - col;
2862                        let p = ((hi >> bit) & 1) << 1 | ((lo >> bit) & 1);
2863                        let palette_byte = self.ppu_bus_peek(0x3F00 + u16::from(p));
2864                        let rgba = rustynes_ppu::nes_color_to_rgba(palette_byte & 0x3F);
2865                        let px = usize::from(tile_x) * TILE_W + usize::from(col);
2866                        let py = usize::from(tile_y) * TILE_W + usize::from(row);
2867                        let off = (py * SHEET_W + px) * 4;
2868                        out[off..off + 4].copy_from_slice(&rgba);
2869                    }
2870                }
2871            }
2872        }
2873        out
2874    }
2875
2876    /// Render a nametable as RGBA8 (256x240).
2877    ///
2878    /// `nt` selects 0..=3 logical nametable. Uses the current
2879    /// BG pattern table base, attribute palette, and CHR data.
2880    pub fn nametable_rgba(&mut self, nt: u8) -> Vec<u8> {
2881        const FB_W: usize = 256;
2882        const FB_H: usize = 240;
2883        let nt = nt & 0x03;
2884        let nt_base = 0x2000u16 + u16::from(nt) * 0x400;
2885        let attr_base = nt_base + 0x3C0;
2886        let bg_base = self.bus.ppu().bg_pattern_base();
2887        let mut out = vec![0u8; FB_W * FB_H * 4];
2888        for ty in 0..30u16 {
2889            for tx in 0..32u16 {
2890                let nt_addr = nt_base + ty * 32 + tx;
2891                let tile_idx = self.ppu_bus_peek(nt_addr);
2892                let attr_addr = attr_base + (ty / 4) * 8 + (tx / 4);
2893                let attr_byte = self.ppu_bus_peek(attr_addr);
2894                let shift = ((ty & 2) << 1) | (tx & 2);
2895                let palette = u16::from((attr_byte >> shift) & 0x03);
2896                for row in 0..8u16 {
2897                    let lo = self.ppu_bus_peek(bg_base + u16::from(tile_idx) * 16 + row);
2898                    let hi = self.ppu_bus_peek(bg_base + u16::from(tile_idx) * 16 + row + 8);
2899                    for col in 0..8u16 {
2900                        let bit = 7 - col;
2901                        let p = ((hi >> bit) & 1) << 1 | ((lo >> bit) & 1);
2902                        let final_idx = if p == 0 {
2903                            self.ppu_bus_peek(0x3F00)
2904                        } else {
2905                            self.ppu_bus_peek(0x3F00 + palette * 4 + u16::from(p))
2906                        };
2907                        let rgba = rustynes_ppu::nes_color_to_rgba(final_idx & 0x3F);
2908                        let px = usize::from(tx * 8 + col);
2909                        let py = usize::from(ty * 8 + row);
2910                        let off = (py * FB_W + px) * 4;
2911                        out[off..off + 4].copy_from_slice(&rgba);
2912                    }
2913                }
2914            }
2915        }
2916        out
2917    }
2918}
2919
2920fn sha256_of(bytes: &[u8]) -> [u8; 32] {
2921    let mut h = Sha256::new();
2922    h.update(bytes);
2923    let out = h.finalize();
2924    let mut a = [0u8; 32];
2925    a.copy_from_slice(&out);
2926    a
2927}
2928
2929#[cfg(test)]
2930mod tests {
2931    use super::*;
2932
2933    /// 16-byte iNES header for a synthetic NROM ROM with `prg_kib`/`chr_kib`
2934    /// content, vertical mirroring.
2935    fn synth_nrom(prg_kib: usize, chr_kib: usize) -> Vec<u8> {
2936        let mut bytes = Vec::with_capacity(16 + prg_kib * 1024 + chr_kib * 1024);
2937        bytes.extend_from_slice(b"NES\x1A");
2938        bytes.push(u8::try_from(prg_kib / 16).unwrap());
2939        bytes.push(u8::try_from(chr_kib / 8).unwrap());
2940        bytes.push(0); // flags6
2941        bytes.push(0); // flags7
2942        bytes.extend_from_slice(&[0u8; 8]);
2943
2944        // PRG payload: a tiny program at $C000 that loops forever (JMP $C000).
2945        // Since the reset vector reads $FFFC/D, we set those bytes too.
2946        let mut prg = vec![0u8; prg_kib * 1024];
2947        if prg_kib >= 16 {
2948            // 16 KiB PRG: $C000-$FFFF maps to bytes 0..$4000 of PRG.
2949            // JMP $C000 -> $4C $00 $C0
2950            prg[0] = 0x4C;
2951            prg[1] = 0x00;
2952            prg[2] = 0xC0;
2953            // Reset vector at $FFFC/D = end-of-PRG offsets.
2954            let len = prg.len();
2955            prg[len - 4] = 0x00;
2956            prg[len - 3] = 0xC0;
2957            // NMI vector at $FFFA/B: same.
2958            prg[len - 6] = 0x00;
2959            prg[len - 5] = 0xC0;
2960            // IRQ vector at $FFFE/F: same.
2961            prg[len - 2] = 0x00;
2962            prg[len - 1] = 0xC0;
2963        }
2964        bytes.extend_from_slice(&prg);
2965        bytes.extend_from_slice(&vec![0u8; chr_kib * 1024]);
2966        bytes
2967    }
2968
2969    /// Synthetic NES 2.0 NROM with console type Vs. System and a byte-13 Vs.
2970    /// PPU type (low nibble).
2971    fn synth_vs_nrom(vs_ppu_low_nibble: u8) -> Vec<u8> {
2972        let mut rom = synth_nrom(16, 8);
2973        // Upgrade the header to NES 2.0 + console type Vs. System.
2974        rom[7] = 0x09; // bits 2-3 = 10 (NES 2.0), bits 0-1 = 01 (Vs. System)
2975        rom[13] = vs_ppu_low_nibble & 0x0F;
2976        rom
2977    }
2978
2979    #[test]
2980    fn nes_cart_4016_read_is_byte_identical_with_and_without_vs_inputs() {
2981        // On a normal NES cart the Vs. DIP/coin/service overlay is a no-op, so
2982        // a $4016/$4017 read is byte-for-byte identical regardless of the Vs.
2983        // input state. Compare two freshly-built buses in lockstep.
2984        let rom = synth_nrom(16, 8);
2985        let mut a = Nes::from_rom(&rom).unwrap();
2986        let mut b = Nes::from_rom(&rom).unwrap();
2987        assert!(!a.is_vs_system());
2988        // Crank the Vs. inputs on `b` only.
2989        b.set_vs_dip(0xFF);
2990        b.insert_coin(0);
2991        b.insert_coin(1);
2992        b.set_vs_service(true);
2993        for addr in [0x4016u16, 0x4017, 0x4016, 0x4017] {
2994            assert_eq!(
2995                a.bus_mut().raw_cpu_read(addr),
2996                b.bus_mut().raw_cpu_read(addr),
2997                "Vs. inputs leaked into a normal-cart read of {addr:#06X}"
2998            );
2999        }
3000    }
3001
3002    /// v1.6.0 Workstream A3 — the `TAStudio` lag-log flag: a frame in which the
3003    /// program never reads `$4016`/`$4017` is a lag frame; a controller read
3004    /// marks the frame polled; and the flag resets at the top of each frame.
3005    #[cfg(feature = "debug-hooks")]
3006    #[test]
3007    fn lag_flag_tracks_controller_reads_per_frame() {
3008        // synth_nrom is a pure `JMP $C000` loop — it never polls input.
3009        let rom = synth_nrom(16, 8);
3010        let mut nes = Nes::from_rom(&rom).unwrap();
3011
3012        // A frame of pure JMP never reads a controller port => lag frame.
3013        nes.run_frame();
3014        assert!(
3015            !nes.was_input_polled_this_frame(),
3016            "a frame with no $4016/$4017 read must be a lag frame"
3017        );
3018
3019        // A controller-port read marks the (current) frame as polled.
3020        let _ = nes.bus_mut().raw_cpu_read(0x4016);
3021        assert!(
3022            nes.was_input_polled_this_frame(),
3023            "a $4016 read must mark the frame polled"
3024        );
3025
3026        // $4017 also counts, and the next frame's clear resets the flag.
3027        nes.run_frame();
3028        assert!(
3029            !nes.was_input_polled_this_frame(),
3030            "the flag must reset at the top of each frame"
3031        );
3032        let _ = nes.bus_mut().raw_cpu_read(0x4017);
3033        assert!(
3034            nes.was_input_polled_this_frame(),
3035            "a $4017 read must also mark the frame polled"
3036        );
3037    }
3038
3039    #[test]
3040    fn vs_dip_switches_read_through_4016_and_4017() {
3041        // 2C03 Vs. cart (low nibble 0).
3042        let rom = synth_vs_nrom(0x0);
3043        let mut nes = Nes::from_rom(&rom).unwrap();
3044        assert!(nes.is_vs_system());
3045        // DIP = 0b1010_1010: sw2,4,6,8 on; sw1,3,5,7 off.
3046        nes.set_vs_dip(0b1010_1010);
3047        let v16 = nes.bus_mut().raw_cpu_read(0x4016);
3048        // $4016: DIP sw1 -> bit3 (off), sw2 -> bit4 (on).
3049        assert_eq!(v16 & 0x08, 0x00, "DIP sw1 off");
3050        assert_eq!(v16 & 0x10, 0x10, "DIP sw2 on");
3051        let v17 = nes.bus_mut().raw_cpu_read(0x4017);
3052        // $4017: DIP sw3..8 -> bits 2..7. DIP bits 2..7 = 0b101010.
3053        assert_eq!(v17 & 0xFC, 0b1010_1000 & 0xFC);
3054    }
3055
3056    #[test]
3057    fn vs_coin_and_service_read_through_4016() {
3058        let rom = synth_vs_nrom(0x0);
3059        let mut nes = Nes::from_rom(&rom).unwrap();
3060        nes.set_vs_dip(0);
3061        // Coin acceptor #1 -> $4016 bit 5.
3062        nes.insert_coin(0);
3063        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x20, 0x20);
3064        // Acceptor #2 -> bit 6.
3065        nes.insert_coin(1);
3066        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x60, 0x60);
3067        nes.clear_coin();
3068        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x60, 0x00);
3069        // Service button -> bit 2.
3070        nes.set_vs_service(true);
3071        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x04, 0x04);
3072        nes.set_vs_service(false);
3073        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x04, 0x00);
3074    }
3075
3076    #[test]
3077    fn game_genie_substitutes_on_cpu_read_path() {
3078        // 16 KiB NROM; plant the Zelda code's compare byte (0x22) at the PRG
3079        // address it targets ($9F41 -> $8000-$BFFF window -> PRG offset $1F41).
3080        let mut rom = synth_nrom(16, 8);
3081        rom[16 + 0x1F41] = 0x22;
3082        let mut nes = Nes::from_rom(&rom).expect("synthetic NROM parses");
3083
3084        // No codes active: reads are the original byte (determinism contract).
3085        assert_eq!(nes.bus_mut().debug_peek_cpu(0x9F41), 0x22);
3086        assert_eq!(nes.bus_mut().peek_cpu(0x9F41), 0x22);
3087        assert_eq!(nes.genie_codes().count(), 0);
3088
3089        // 8-char code substitutes only when the original matches compare (0x22),
3090        // on BOTH the production read path and the debugger peek path.
3091        nes.add_genie_code("YYKPOYZZ").expect("valid 8-char code");
3092        assert_eq!(
3093            nes.bus_mut().debug_peek_cpu(0x9F41),
3094            0x77,
3095            "debug peek substituted"
3096        );
3097        assert_eq!(
3098            nes.bus_mut().peek_cpu(0x9F41),
3099            0x77,
3100            "production read substituted"
3101        );
3102        assert_eq!(
3103            nes.bus_mut().debug_peek_cpu(0x9F40),
3104            0x00,
3105            "other address untouched"
3106        );
3107
3108        // Removal (case-insensitive) restores the original byte.
3109        nes.remove_genie_code("yykpoyzz");
3110        assert_eq!(nes.bus_mut().debug_peek_cpu(0x9F41), 0x22);
3111
3112        // 6-char code (no compare) always substitutes; $91D9 -> data 0xAD.
3113        nes.add_genie_code("SXIOPO").expect("valid 6-char code");
3114        assert_eq!(nes.bus_mut().debug_peek_cpu(0x91D9), 0xAD);
3115        nes.clear_genie_codes();
3116        assert_eq!(nes.bus_mut().debug_peek_cpu(0x91D9), 0x00);
3117
3118        // A malformed code is rejected without mutating state.
3119        assert!(nes.add_genie_code("BADCODE!").is_err());
3120    }
3121
3122    #[test]
3123    fn poke_ram_writes_system_ram_and_ignores_rom() {
3124        let rom = synth_nrom(16, 8);
3125        let mut nes = Nes::from_rom(&rom).expect("synthetic NROM parses");
3126        nes.poke_ram(0x0042, 0xAB);
3127        assert_eq!(nes.bus_mut().debug_peek_cpu(0x0042), 0xAB);
3128        // Mirrored every $800 within $0000-$1FFF.
3129        assert_eq!(nes.bus_mut().debug_peek_cpu(0x0842), 0xAB);
3130        // A poke outside system RAM is a no-op (no panic; ROM space untouched).
3131        nes.poke_ram(0x8000, 0xFF);
3132        assert_ne!(nes.bus_mut().debug_peek_cpu(0x8000), 0xFF);
3133    }
3134
3135    #[test]
3136    fn nes_set_buttons_then_strobe_reads_bits_in_order() {
3137        // T-51-005: end-to-end controller plumbing — the bus must shift the
3138        // latched button state out via $4016 in canonical order.
3139        //
3140        // Session-24 / Phase 3 update: `$4016` writes are now deferred
3141        // (committed at the next M2-low boundary inside
3142        // `tick_one_cpu_cycle`).  Direct-API callers that bypass CPU
3143        // stepping must tick the bus between the strobe pulse and the
3144        // shift-out reads so the buffered write commits.  Two ticks
3145        // are sufficient (one for the pending=1 commit, one as a
3146        // margin in case the test's first write landed on the pending=2
3147        // path).
3148        use rustynes_cpu::Bus as _;
3149        let rom = synth_nrom(16, 8);
3150        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3151        nes.set_buttons(0, Buttons::A | Buttons::SELECT | Buttons::DOWN);
3152
3153        // Pulse the strobe latch (write 1 then 0 to $4016), driving the
3154        // bus enough cycles between writes for the deferred-write
3155        // commit to land.
3156        nes.bus_mut().cpu_write(0x4016, 1);
3157        nes.bus_mut().tick_one_cpu_cycle();
3158        nes.bus_mut().tick_one_cpu_cycle();
3159        nes.bus_mut().cpu_write(0x4016, 0);
3160        nes.bus_mut().tick_one_cpu_cycle();
3161        nes.bus_mut().tick_one_cpu_cycle();
3162
3163        // 8 reads of $4016 should yield A, B, Select, Start, Up, Down, Left, Right.
3164        let expected = [1u8, 0, 1, 0, 0, 1, 0, 0];
3165        for &want in &expected {
3166            let v = nes.bus_mut().cpu_read(0x4016) & 1;
3167            assert_eq!(v, want);
3168        }
3169    }
3170
3171    #[test]
3172    fn nes_set_buttons_port1_reads_via_4017_in_order() {
3173        // T-71-004 (Phase 7): player 2 plumbing. The strobe latch is shared
3174        // (writing `$4016` strobes BOTH pads); player 2 shifts out on `$4017`.
3175        // Mirrors `nes_set_buttons_then_strobe_reads_bits_in_order` for port 1.
3176        use rustynes_cpu::Bus as _;
3177        let rom = synth_nrom(16, 8);
3178        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3179        nes.set_buttons(1, Buttons::B | Buttons::START | Buttons::RIGHT);
3180
3181        nes.bus_mut().cpu_write(0x4016, 1);
3182        nes.bus_mut().tick_one_cpu_cycle();
3183        nes.bus_mut().tick_one_cpu_cycle();
3184        nes.bus_mut().cpu_write(0x4016, 0);
3185        nes.bus_mut().tick_one_cpu_cycle();
3186        nes.bus_mut().tick_one_cpu_cycle();
3187
3188        // A, B, Select, Start, Up, Down, Left, Right.
3189        let expected = [0u8, 1, 0, 1, 0, 0, 0, 1];
3190        for (i, &want) in expected.iter().enumerate() {
3191            let v = nes.bus_mut().cpu_read(0x4017) & 1;
3192            assert_eq!(v, want, "$4017 read #{i}");
3193        }
3194    }
3195
3196    #[test]
3197    fn nes_restrobe_relatches_current_buttons() {
3198        // T-71-004 (Phase 7): a fresh strobe re-samples the live button state
3199        // through the full bus (the per-`Controller` unit test in
3200        // `controller.rs` covers this at the chip level; this confirms it end
3201        // to end via `Nes::set_buttons` + `$4016`).
3202        use rustynes_cpu::Bus as _;
3203        let rom = synth_nrom(16, 8);
3204        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3205
3206        let strobe = |nes: &mut Nes| {
3207            nes.bus_mut().cpu_write(0x4016, 1);
3208            nes.bus_mut().tick_one_cpu_cycle();
3209            nes.bus_mut().tick_one_cpu_cycle();
3210            nes.bus_mut().cpu_write(0x4016, 0);
3211            nes.bus_mut().tick_one_cpu_cycle();
3212            nes.bus_mut().tick_one_cpu_cycle();
3213        };
3214
3215        nes.set_buttons(0, Buttons::A);
3216        strobe(&mut nes);
3217        assert_eq!(nes.bus_mut().cpu_read(0x4016) & 1, 1, "A latched pressed");
3218
3219        // Change state, then re-strobe: the new state must be visible.
3220        nes.set_buttons(0, Buttons::empty());
3221        strobe(&mut nes);
3222        assert_eq!(nes.bus_mut().cpu_read(0x4016) & 1, 0, "A latched released");
3223    }
3224
3225    #[test]
3226    fn reading_4015_does_not_refresh_external_open_bus() {
3227        // T-72-006 (Phase 7): `$4015` reads return the APU status but do NOT
3228        // drive the external data bus (the APU status port is internal to the
3229        // 2A03 package). So a `$4015` read must leave the open-bus latch
3230        // unchanged — a subsequent open-bus-region read returns the prior
3231        // floating value, not the APU status. Per nesdev "Open bus behavior"
3232        // + AccuracyCoin `CPU Behavior :: Open Bus` Test 7.
3233        use rustynes_cpu::Bus as _;
3234        let rom = synth_nrom(16, 8);
3235        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3236
3237        // Drive a known value onto the external bus via a normal RAM read.
3238        nes.bus_mut().cpu_write(0x0010, 0xAB);
3239        assert_eq!(nes.bus_mut().cpu_read(0x0010), 0xAB);
3240        // $4018-$401F is open-bus region: returns (and re-latches) the value.
3241        assert_eq!(
3242            nes.bus_mut().cpu_read(0x4018),
3243            0xAB,
3244            "open-bus latch holds 0xAB"
3245        );
3246
3247        // Read $4015 — must NOT refresh the external latch.
3248        let _ = nes.bus_mut().cpu_read(0x4015);
3249
3250        // The latch is still 0xAB, not whatever APU status $4015 returned.
3251        assert_eq!(
3252            nes.bus_mut().cpu_read(0x4018),
3253            0xAB,
3254            "$4015 read must not drive the external data bus"
3255        );
3256    }
3257
3258    #[test]
3259    fn nes_from_rom_constructs_and_resets() {
3260        let rom = synth_nrom(16, 8);
3261        let nes = Nes::from_rom(&rom).expect("parse + boot");
3262        assert_eq!(nes.cpu().pc, 0xC000);
3263    }
3264
3265    #[test]
3266    fn power_on_randomization_is_opt_in_seeded_and_deterministic() {
3267        // T-72-005 (Phase 7): the default path leaves work RAM zeroed; the
3268        // seeded constructor randomizes it deterministically.
3269        let rom = synth_nrom(16, 8);
3270
3271        // Default: RAM is zeroed.
3272        let mut default = Nes::from_rom(&rom).expect("parse + boot");
3273        for addr in (0x0000u16..0x0800).step_by(0x40) {
3274            assert_eq!(default.cpu_bus_peek(addr), 0, "default RAM must be zero");
3275        }
3276
3277        // Seeded: RAM is not all-zero.
3278        let mut a = Nes::from_rom_with_power_on_seed(&rom, 1).expect("parse + boot");
3279        let dump_a: Vec<u8> = (0x0000u16..0x0100).map(|x| a.cpu_bus_peek(x)).collect();
3280        assert!(
3281            dump_a.iter().any(|&b| b != 0),
3282            "seeded RAM must not be all zero"
3283        );
3284
3285        // Same seed -> identical RAM.
3286        let mut a2 = Nes::from_rom_with_power_on_seed(&rom, 1).expect("parse + boot");
3287        let dump_a2: Vec<u8> = (0x0000u16..0x0100).map(|x| a2.cpu_bus_peek(x)).collect();
3288        assert_eq!(
3289            dump_a, dump_a2,
3290            "same seed must yield identical power-on RAM"
3291        );
3292
3293        // Different seed -> different RAM.
3294        let mut b = Nes::from_rom_with_power_on_seed(&rom, 0xDEAD_BEEF).expect("parse + boot");
3295        let dump_b: Vec<u8> = (0x0000u16..0x0100).map(|x| b.cpu_bus_peek(x)).collect();
3296        assert_ne!(dump_a, dump_b, "different seeds should differ");
3297    }
3298
3299    #[test]
3300    fn power_on_config_defaults_byte_identical_and_variants_deterministic() {
3301        // v2.1.7 P5 — the PowerOnConfig surface. Default (Zeroed) must match the
3302        // plain constructor; Filled + Seeded must be deterministic and distinct.
3303        let rom = synth_nrom(16, 8);
3304
3305        // Default config == from_rom (byte-identical work RAM).
3306        let mut zeroed =
3307            Nes::from_rom_with_power_on_config(&rom, PowerOnConfig::default()).expect("boot");
3308        assert_eq!(zeroed.power_on_ram(), PowerOnRam::Zeroed);
3309        for addr in (0x0000u16..0x0800).step_by(0x40) {
3310            assert_eq!(zeroed.cpu_bus_peek(addr), 0, "Zeroed config: RAM zero");
3311        }
3312
3313        // Filled(0xFF): every work-RAM byte is 0xFF, deterministically.
3314        let mut filled = Nes::from_rom_with_power_on_config(
3315            &rom,
3316            PowerOnConfig {
3317                ram: PowerOnRam::Filled(0xFF),
3318            },
3319        )
3320        .expect("boot");
3321        assert_eq!(filled.power_on_ram(), PowerOnRam::Filled(0xFF));
3322        for addr in (0x0000u16..0x0800).step_by(0x40) {
3323            assert_eq!(filled.cpu_bus_peek(addr), 0xFF, "Filled(0xFF)");
3324        }
3325
3326        // Seeded is deterministic and differs from Zeroed.
3327        let mut seeded = Nes::from_rom_with_power_on_config(
3328            &rom,
3329            PowerOnConfig {
3330                ram: PowerOnRam::Seeded(42),
3331            },
3332        )
3333        .expect("boot");
3334        let dump: Vec<u8> = (0x0000u16..0x0100)
3335            .map(|x| seeded.cpu_bus_peek(x))
3336            .collect();
3337        assert!(dump.iter().any(|&b| b != 0), "Seeded: not all zero");
3338    }
3339
3340    #[test]
3341    fn ppu_revision_and_palette_default_byte_identical() {
3342        // v2.1.7 P5 — the PPU-revision + power-up-palette knobs default to the
3343        // byte-identical state, and toggling them is observable + power-cycle
3344        // durable.
3345        let rom = synth_nrom(16, 8);
3346        let mut nes = Nes::from_rom(&rom).expect("boot");
3347        assert_eq!(nes.ppu_revision(), PpuRevision::Rp2c02H);
3348        assert_eq!(nes.power_up_palette(), PaletteInit::Zeroed);
3349
3350        // Select the opt-in revision + Blargg palette; both must persist across a
3351        // power-cycle (the bus re-applies them after rebuilding the PPU).
3352        nes.set_ppu_revision(PpuRevision::Rp2c02G);
3353        nes.set_power_up_palette(PaletteInit::Blargg);
3354        nes.power_cycle();
3355        assert_eq!(
3356            nes.ppu_revision(),
3357            PpuRevision::Rp2c02G,
3358            "revision survives power-cycle"
3359        );
3360        assert_eq!(
3361            nes.power_up_palette(),
3362            PaletteInit::Blargg,
3363            "palette survives power-cycle"
3364        );
3365    }
3366
3367    #[test]
3368    fn nes_run_frame_completes_and_returns_framebuffer() {
3369        let rom = synth_nrom(16, 8);
3370        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3371        let fb = nes.run_frame();
3372        assert_eq!(fb.len(), 256 * 240 * 4);
3373    }
3374
3375    #[test]
3376    fn nes_run_two_frames_distinct_completion_latches() {
3377        let rom = synth_nrom(16, 8);
3378        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3379        nes.run_frame();
3380        let cycles_after_one = nes.cycle();
3381        nes.run_frame();
3382        let cycles_after_two = nes.cycle();
3383        assert!(cycles_after_two > cycles_after_one);
3384    }
3385
3386    #[test]
3387    fn nes_determinism_two_runs_match() {
3388        // T-24-002: same ROM + zero input + 60 frames -> bit-identical
3389        // framebuffer hash via FNV-1a.
3390        fn hash_fb(fb: &[u8]) -> u64 {
3391            let mut h: u64 = 0xCBF2_9CE4_8422_2325;
3392            for &b in fb {
3393                h ^= u64::from(b);
3394                h = h.wrapping_mul(0x0000_0100_0000_01B3);
3395            }
3396            h
3397        }
3398        let rom = synth_nrom(16, 8);
3399        let mut a = Nes::from_rom(&rom).unwrap();
3400        let mut b = Nes::from_rom(&rom).unwrap();
3401        let frames = 4;
3402        let mut hash_a = 0u64;
3403        let mut hash_b = 0u64;
3404        for _ in 0..frames {
3405            hash_a = hash_fb(a.run_frame());
3406            hash_b = hash_fb(b.run_frame());
3407        }
3408        assert_eq!(
3409            hash_a, hash_b,
3410            "two runs must produce identical framebuffer"
3411        );
3412    }
3413
3414    fn fnv_hash(bytes: &[u8]) -> u64 {
3415        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
3416        for &b in bytes {
3417            h ^= u64::from(b);
3418            h = h.wrapping_mul(0x0000_0100_0000_01B3);
3419        }
3420        h
3421    }
3422
3423    #[test]
3424    fn snapshot_round_trip_preserves_framebuffer_and_cycle() {
3425        let rom = synth_nrom(16, 8);
3426        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3427        for _ in 0..4 {
3428            nes.run_frame();
3429        }
3430        let cycle = nes.cycle();
3431        let fb_hash_before = fnv_hash(nes.framebuffer());
3432        let blob = nes.snapshot();
3433
3434        // Drift the emulator forward 4 more frames so it looks different.
3435        for _ in 0..4 {
3436            nes.run_frame();
3437        }
3438        assert_ne!(nes.cycle(), cycle, "drift must move us off the snapshot");
3439
3440        nes.restore(&blob).expect("restore");
3441        assert_eq!(nes.cycle(), cycle);
3442        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash_before);
3443    }
3444
3445    #[test]
3446    fn snapshot_is_deterministic_across_two_runs() {
3447        let rom = synth_nrom(16, 8);
3448        let mut a = Nes::from_rom(&rom).unwrap();
3449        let mut b = Nes::from_rom(&rom).unwrap();
3450        for _ in 0..3 {
3451            a.run_frame();
3452            b.run_frame();
3453        }
3454        assert_eq!(a.snapshot(), b.snapshot());
3455    }
3456
3457    #[test]
3458    fn snapshot_header_carries_rom_hash_tag() {
3459        let rom = synth_nrom(16, 8);
3460        let nes = Nes::from_rom(&rom).unwrap();
3461        let blob = nes.snapshot();
3462        let (h, _off) = save_state::parse_header(&blob).unwrap();
3463        assert_eq!(h.rom_hash_tag, nes.rom_hash_tag());
3464    }
3465
3466    #[test]
3467    fn restore_rejects_pre_v3_cpu_section_version() {
3468        // ADR 0028 (v2.0.0 rc.1): a slot file whose CPU section predates the
3469        // one-clock promote (schema version < CPU_SNAPSHOT_VERSION) must be
3470        // cleanly rejected via SnapshotError::VersionMismatch, not silently
3471        // accepted or upconverted. Simulate an old slot by taking a
3472        // freshly-emitted (current-version) snapshot and patching only the
3473        // CPU section's version byte down to a stale value -- everything
3474        // else (the body bytes, every other section) is untouched, so this
3475        // isolates the version-gate behavior from any layout difference.
3476        let rom = synth_nrom(16, 8);
3477        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3478        for _ in 0..3 {
3479            nes.run_frame();
3480        }
3481        let current = nes.snapshot();
3482        let (_h, body_off) = save_state::parse_header(&current).unwrap();
3483        let mut stale = current[..body_off].to_vec();
3484        for s in save_state::SectionIter::new(&current[body_off..]) {
3485            let s = s.unwrap();
3486            let version = if s.tag == save_state::tag::CPU {
3487                assert_eq!(
3488                    s.version,
3489                    rustynes_cpu::CPU_SNAPSHOT_VERSION,
3490                    "fixture assumption: current build writes the current CPU version"
3491                );
3492                s.version - 1
3493            } else {
3494                s.version
3495            };
3496            save_state::write_section(&mut stale, s.tag, version, s.body);
3497        }
3498        let err = nes.restore(&stale).unwrap_err();
3499        assert!(
3500            matches!(
3501                err,
3502                SnapshotError::VersionMismatch { ref tag, .. } if tag == "CPU "
3503            ),
3504            "expected a CPU-tagged VersionMismatch, got {err:?}"
3505        );
3506    }
3507
3508    #[test]
3509    fn rewind_step_back_restores_prior_frame() {
3510        let rom = synth_nrom(16, 8);
3511        let mut nes = Nes::from_rom(&rom).unwrap();
3512        nes.enable_rewind_with(2 * 1024 * 1024, 1);
3513        for _ in 0..6 {
3514            nes.run_frame();
3515        }
3516        let cycle_at_6 = nes.cycle();
3517        nes.run_frame();
3518        nes.run_frame();
3519        nes.run_frame();
3520        // 3 entries on the ring (frames 6..=8 captured at the END of each
3521        // run_frame — frame 5 was captured in the loop above).
3522        assert!(nes.rewind_step_back(), "first step back");
3523        assert!(nes.rewind_step_back(), "second step back");
3524        assert!(nes.rewind_step_back(), "third step back");
3525        // We've rewound past the 3 extra frames; cycle should equal the
3526        // state we captured at the end of frame 6 (i.e. frame 5's snap).
3527        assert_ne!(nes.cycle(), cycle_at_6, "captured frame 5, not frame 6");
3528    }
3529
3530    // ---- v2.4.0 item B — the timeline generation counter ----
3531
3532    /// A LOUD restore is a timeline jump and must bump the generation.
3533    ///
3534    /// This is the defect the counter exists for: v2.3.9 cleared stale debug
3535    /// telemetry on a ROM change and could not clear it on a save-state load,
3536    /// because two of the four jump paths are not reachable from a patchable
3537    /// frontend call site.
3538    #[test]
3539    fn a_loud_restore_bumps_the_timeline_generation() {
3540        let rom = synth_nrom(16, 8);
3541        let mut nes = Nes::from_rom(&rom).unwrap();
3542        nes.run_frame();
3543        let blob = nes.snapshot();
3544        let before = nes.timeline_generation();
3545        nes.run_frame();
3546        nes.restore(&blob).expect("restore");
3547        assert!(
3548            nes.timeline_generation() > before,
3549            "a user-driven load did not register as a timeline jump"
3550        );
3551    }
3552
3553    /// A QUIET restore is the SAME timeline and must NOT bump.
3554    ///
3555    /// Run-ahead restores every frame and netplay rollback restores on every
3556    /// correction. Bumping here would clear a consumer's telemetry sixty times a
3557    /// second — worse than the stale-telemetry defect the counter fixes.
3558    #[test]
3559    fn a_quiet_restore_does_not_bump_the_timeline_generation() {
3560        let rom = synth_nrom(16, 8);
3561        let mut nes = Nes::from_rom(&rom).unwrap();
3562        nes.run_frame();
3563        let blob = nes.snapshot();
3564        let before = nes.timeline_generation();
3565        nes.run_frame();
3566        nes.restore_quiet(&blob).expect("quiet restore");
3567        assert_eq!(
3568            nes.timeline_generation(),
3569            before,
3570            "a same-timeline restore was reported as a jump; under run-ahead this \
3571             fires every frame"
3572        );
3573    }
3574
3575    /// Rewind is a jump, and it reaches the counter without its own call site.
3576    #[test]
3577    fn rewind_bumps_the_timeline_generation() {
3578        let rom = synth_nrom(16, 8);
3579        let mut nes = Nes::from_rom(&rom).unwrap();
3580        nes.enable_rewind_with(2 * 1024 * 1024, 1);
3581        for _ in 0..4 {
3582            nes.run_frame();
3583        }
3584        let before = nes.timeline_generation();
3585        assert!(nes.rewind_step_back(), "step back");
3586        assert!(
3587            nes.timeline_generation() > before,
3588            "rewind did not register as a timeline jump"
3589        );
3590    }
3591
3592    /// Reset and power-cycle are discontinuities too.
3593    #[test]
3594    fn reset_and_power_cycle_bump_the_timeline_generation() {
3595        let rom = synth_nrom(16, 8);
3596        let mut nes = Nes::from_rom(&rom).unwrap();
3597        let a = nes.timeline_generation();
3598        nes.reset();
3599        let b = nes.timeline_generation();
3600        assert!(b > a, "warm reset did not bump");
3601        nes.power_cycle();
3602        assert!(nes.timeline_generation() > b, "power cycle did not bump");
3603    }
3604
3605    /// **The counter must not be serialized**, and this is the assertion that
3606    /// pins it.
3607    ///
3608    /// Serializing it would put an OLD value back on restore, so loading a state
3609    /// saved earlier in the same session could hand a consumer a generation it
3610    /// has already seen — and the consumer would conclude nothing jumped at the
3611    /// exact moment something did. This test reproduces precisely that shape:
3612    /// snapshot at generation N, advance the generation past N, then restore. If
3613    /// the counter round-tripped, the value would come back as N.
3614    #[test]
3615    fn a_restore_never_hands_back_a_generation_a_consumer_has_seen() {
3616        let rom = synth_nrom(16, 8);
3617        let mut nes = Nes::from_rom(&rom).unwrap();
3618        nes.run_frame();
3619        let blob = nes.snapshot();
3620        let at_snapshot = nes.timeline_generation();
3621
3622        // Advance the generation well past the snapshot's value.
3623        for _ in 0..3 {
3624            nes.reset();
3625        }
3626        let seen = nes.timeline_generation();
3627        assert!(seen > at_snapshot);
3628
3629        nes.restore(&blob).expect("restore");
3630        assert!(
3631            nes.timeline_generation() > seen,
3632            "the generation went BACKWARDS to {} (a consumer had already seen {seen}), \
3633             so the counter is being carried in the save state -- which defeats its \
3634             only purpose",
3635            nes.timeline_generation()
3636        );
3637    }
3638
3639    #[test]
3640    fn rewind_disabled_no_op() {
3641        let rom = synth_nrom(16, 8);
3642        let mut nes = Nes::from_rom(&rom).unwrap();
3643        nes.run_frame();
3644        assert!(!nes.rewind_step_back());
3645        assert_eq!(nes.rewind_len(), 0);
3646    }
3647
3648    #[cfg(feature = "debug-hooks")]
3649    #[test]
3650    fn breakpoint_stops_run_frame_at_pc() {
3651        let rom = synth_nrom(16, 8);
3652        // A PC the CPU provably reaches: the PC after the first 3 executed
3653        // instructions (a fresh run replays the same deterministic sequence).
3654        let mut probe = Nes::from_rom(&rom).expect("parse");
3655        for _ in 0..3 {
3656            probe.step_instruction();
3657        }
3658        let target = probe.cpu.pc;
3659
3660        let mut nes = Nes::from_rom(&rom).expect("parse");
3661        // The PPU's frame-complete latch is set at power-on, so the first
3662        // `run_frame` returns immediately without iterating; warm past it.
3663        let _ = nes.run_frame();
3664        nes.add_breakpoint(target);
3665        nes.add_breakpoint(target); // idempotent
3666        assert_eq!(nes.breakpoints(), &[target]);
3667
3668        let _ = nes.run_frame();
3669        assert_eq!(
3670            nes.take_break_hit(),
3671            Some(target),
3672            "stops at the breakpoint PC"
3673        );
3674        assert_eq!(nes.take_break_hit(), None, "hit cleared on read");
3675
3676        // Resuming ("continue") steps past the stopped PC instead of
3677        // re-breaking in place, then hits the same PC again next loop.
3678        let _ = nes.run_frame();
3679        assert_eq!(
3680            nes.take_break_hit(),
3681            Some(target),
3682            "resume steps past, then re-hits on the next pass"
3683        );
3684
3685        // Disarmed breakpoints don't fire (the frame runs to completion).
3686        nes.set_breakpoints_enabled(false);
3687        let _ = nes.run_frame();
3688        assert_eq!(nes.take_break_hit(), None, "disarmed: no break");
3689
3690        // Removal empties the list.
3691        nes.remove_breakpoint(target);
3692        assert!(nes.breakpoints().is_empty());
3693
3694        // Regression (gemini #41): a breakpoint sitting at the frame's STARTING
3695        // PC must fire immediately — the old `first_iter` skip missed it.
3696        let mut nes2 = Nes::from_rom(&rom).expect("parse");
3697        let _ = nes2.run_frame(); // warm past the power-on frame-complete latch
3698        let start_pc = nes2.cpu.pc;
3699        nes2.add_breakpoint(start_pc);
3700        let _ = nes2.run_frame();
3701        assert_eq!(
3702            nes2.take_break_hit(),
3703            Some(start_pc),
3704            "breaks immediately when starting already on a breakpoint"
3705        );
3706    }
3707
3708    #[cfg(feature = "debug-hooks")]
3709    #[test]
3710    fn trace_logger_records_while_enabled() {
3711        let rom = synth_nrom(16, 8);
3712        let mut nes = Nes::from_rom(&rom).expect("parse");
3713        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
3714        assert_eq!(nes.trace_len(), 0, "off by default");
3715        nes.set_trace_enabled(true);
3716        let _ = nes.run_frame();
3717        assert!(nes.trace_len() > 0, "records while enabled");
3718        // Records carry the executed PCs (the synth ROM spins at $C000).
3719        let recs = nes.trace_records();
3720        assert!(recs.iter().any(|r| r.pc == 0xC000), "captured the loop PC");
3721        // The tail copy is bounded.
3722        assert!(nes.trace_tail_vec(4).len() <= 4);
3723        // Disabling stops growth; clearing empties.
3724        nes.set_trace_enabled(false);
3725        let n = nes.trace_len();
3726        let _ = nes.run_frame();
3727        assert_eq!(nes.trace_len(), n, "no new records when disabled");
3728        nes.clear_trace();
3729        assert_eq!(nes.trace_len(), 0);
3730    }
3731
3732    #[cfg(feature = "debug-hooks")]
3733    #[test]
3734    fn event_viewer_records_writes_with_ppu_position() {
3735        use crate::bus::EventKind;
3736        // A tiny NROM that loops `LDA #$00 ; STA $2000 ; JMP $C000`, so it
3737        // generates a PPU-register write ($2000) every iteration.
3738        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
3739        bytes[0..4].copy_from_slice(b"NES\x1A");
3740        bytes[4] = 1; // 1x16KB PRG
3741        bytes[5] = 1; // 1x8KB CHR (unused here)
3742        let prg = &mut bytes[16..16 + 16 * 1024];
3743        // $C000 maps to PRG offset 0.
3744        prg[0..8].copy_from_slice(&[0xA9, 0x00, 0x8D, 0x00, 0x20, 0x4C, 0x00, 0xC0]);
3745        let len = 16 * 1024;
3746        prg[len - 4] = 0x00; // reset vector lo
3747        prg[len - 3] = 0xC0; // reset vector hi -> $C000
3748        // CHR not appended (header says 1 bank but parse tolerates; use 0 banks).
3749        bytes[5] = 0;
3750
3751        let mut nes = Nes::from_rom(&bytes).expect("parse");
3752        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
3753        assert!(nes.events().is_empty(), "off by default");
3754        nes.set_event_logging(true);
3755        let _ = nes.run_frame();
3756        let evs = nes.events();
3757        assert!(!evs.is_empty(), "the STA $2000 loop produces writes");
3758        assert!(
3759            evs.iter().all(|e| e.kind == EventKind::PpuWrite
3760                && e.addr == 0x2000
3761                && e.dot <= 340
3762                && e.value == 0x00),
3763            "all events are $2000 PPU writes of $00 with a sane dot"
3764        );
3765        // Reset per frame: the count stays one-frame-bounded. The event log is
3766        // capped at `EVENT_CAP` (20_000, private to the bus module) — distinct
3767        // from the looser instruction-trace `TRACE_CAP` — so assert that bound.
3768        let _ = nes.run_frame();
3769        assert!(nes.events().len() <= 20_000, "bounded by EVENT_CAP");
3770        nes.set_event_logging(false);
3771        assert!(!nes.event_logging());
3772    }
3773
3774    /// v2.3.2 "Lucid" Phase 1 — the write-attribution oracle.
3775    ///
3776    /// The claim under test is the whole point of the feature: for a byte in the
3777    /// PPU's own memory, the store reports the PC of the instruction that put it
3778    /// there. The ROM is written so the answer is known independently — a single
3779    /// `STA $2007` at a fixed address — and the expectation is pinned to that
3780    /// address rather than to whatever the implementation happens to record.
3781    #[cfg(feature = "debug-hooks")]
3782    #[test]
3783    fn write_attribution_names_the_instruction_that_wrote_a_nametable_byte() {
3784        // NROM at $C000:
3785        //   C000: A9 21     LDA #$21        ; VRAM addr hi
3786        //   C002: 8D 06 20  STA $2006
3787        //   C005: A9 08     LDA #$08        ; VRAM addr lo -> $2108
3788        //   C007: 8D 06 20  STA $2006
3789        //   C00A: A9 5A     LDA #$5A        ; the byte
3790        //   C00C: 8D 07 20  STA $2007       <-- the write under test
3791        //   C00F: 4C 00 C0  JMP $C000       ; loop the whole sequence
3792        //
3793        // The sequence LOOPS rather than spinning after one pass, and the test
3794        // runs several frames, because the PPU ignores `$2000/$2001/$2005/$2006`
3795        // writes for ~29,658 CPU cycles after reset (the documented post-reset
3796        // mask window, `PpuRegion::post_reset_mask_cycles`). A single pass at
3797        // power-on would have its two `$2006` stores dropped, leaving `v == 0`,
3798        // and the `$2007` write would land in CHR space instead of a nametable.
3799        const STA_2007_PC: u16 = 0xC00C;
3800        const VRAM_ADDR: u16 = 0x2108;
3801        const VALUE: u8 = 0x5A;
3802
3803        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
3804        bytes[0..4].copy_from_slice(b"NES\x1A");
3805        bytes[4] = 1; // 1x16KB PRG
3806        bytes[5] = 0; // no CHR bank appended
3807        let prg = &mut bytes[16..16 + 16 * 1024];
3808        prg[0..18].copy_from_slice(&[
3809            0xA9, 0x21, // LDA #$21
3810            0x8D, 0x06, 0x20, // STA $2006
3811            0xA9, 0x08, // LDA #$08
3812            0x8D, 0x06, 0x20, // STA $2006
3813            0xA9, VALUE, // LDA #$5A
3814            0x8D, 0x07, 0x20, // STA $2007
3815            0x4C, 0x00, 0xC0, // JMP $C000
3816        ]);
3817        let len = 16 * 1024;
3818        prg[len - 4] = 0x00; // reset vector lo
3819        prg[len - 3] = 0xC0; // reset vector hi -> $C000
3820
3821        let mut nes = Nes::from_rom(&bytes).expect("parse");
3822        assert!(
3823            nes.write_attribution().is_none(),
3824            "attribution is off by default"
3825        );
3826        nes.set_write_attribution(true);
3827        // Three frames: one to clear the post-reset write-mask window, the rest
3828        // so the loop's `$2006`/`$2007` sequence lands for real.
3829        for _ in 0..3 {
3830            let _ = nes.run_frame();
3831        }
3832
3833        // Resolve the address the way the emulator does, through the mapper's
3834        // mirroring, rather than hardcoding `& 0x07FF`. The previous version of
3835        // this line claimed to do that and then hardcoded it anyway (review
3836        // catch on PR #356) — which would have masked a mirroring regression.
3837        let off = nes
3838            .ciram_offset_for_nametable_addr(VRAM_ADDR)
3839            .expect("a nametable address resolves to a CIRAM offset");
3840        let attrib = nes.write_attribution().expect("armed");
3841        let rec = attrib
3842            .ciram(off)
3843            .expect("the STA $2007 wrote this CIRAM byte");
3844        assert_eq!(
3845            rec.pc, STA_2007_PC,
3846            "the byte is attributed to the STA $2007, not to the $2006 stores \
3847             that set the address or to the LDA that loaded the value"
3848        );
3849        assert_eq!(rec.value, VALUE);
3850        // The byte really is there — attribution must describe a write that
3851        // actually happened, not a write the tap merely observed being issued.
3852        assert_eq!(nes.vram()[off], VALUE);
3853        // And the cycle stamp is a real one from this run.
3854        assert!(rec.cycle > 0, "cycle stamp taken from the executing CPU");
3855
3856        // An untouched byte reports nothing rather than a plausible-looking zero.
3857        assert_eq!(attrib.ciram(off ^ 0x0400), None);
3858
3859        // Disarming frees the store; re-arming starts clean.
3860        nes.set_write_attribution(false);
3861        assert!(nes.write_attribution().is_none());
3862    }
3863
3864    /// v2.3.2 "Lucid" phase 2 — the per-pixel provenance oracle.
3865    ///
3866    /// Builds a screen out of a known nametable byte and a known palette, then
3867    /// checks that the record for a background pixel names the addresses that
3868    /// actually produced it. The load-bearing assertion is `nt_addr`: it must be
3869    /// the address of the tile ON SCREEN, which is two tiles behind whatever `v`
3870    /// holds at emit time — the single mistake this whole cascade exists to
3871    /// prevent.
3872    #[cfg(feature = "debug-hooks")]
3873    #[test]
3874    fn pixel_provenance_names_the_displayed_tile_not_the_fetch_pointer() {
3875        use rustynes_ppu::PixelLayer;
3876
3877        // NROM with CHR-RAM. The program:
3878        //   * fills nametable $2000 with tile $01,
3879        //   * writes a non-zero pattern for tile $01 into CHR-RAM,
3880        //   * sets palette entry $3F01 to a known color,
3881        //   * enables background rendering,
3882        //   * spins.
3883        //
3884        // Assembled by hand below; addresses are named so the assertions can
3885        // reference the instruction rather than a magic number.
3886        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
3887        bytes[0..4].copy_from_slice(b"NES\x1A");
3888        bytes[4] = 1; // 1x16KB PRG
3889        bytes[5] = 0; // 0 CHR banks => CHR-RAM, so the pattern is writable
3890        let prg = &mut bytes[16..16 + 16 * 1024];
3891
3892        #[rustfmt::skip]
3893        let code: &[u8] = &[
3894            // --- Delay ~328k cycles (~11 frames) BEFORE touching any PPU
3895            // register. The PPU ignores $2000/$2001/$2005/$2006 writes for
3896            // ~29,658 CPU cycles after reset; setup that runs inside that window
3897            // has its $2006 address writes silently dropped, so every subsequent
3898            // $2007 lands somewhere unintended. Found by this test failing.
3899            0xA2, 0x00,                         // C000 LDX #$00
3900            0xA0, 0x00,                         // C002 LDY #$00
3901            0x88,                               // C004 DEY
3902            0xD0, 0xFD,                         // C005 BNE $C004
3903            0xCA,                               // C007 DEX
3904            0xD0, 0xF8,                         // C008 BNE $C002
3905            // --- CHR-RAM: tile $01 rows 0..7 low plane = $FF (all pixels idx 1)
3906            0xA9, 0x00, 0x8D, 0x06, 0x20,       // C00A LDA #$00 / STA $2006
3907            0xA9, 0x10, 0x8D, 0x06, 0x20,       // C00F LDA #$10 / STA $2006  -> $0010
3908            0xA2, 0x08,                         // C014 LDX #$08
3909            0xA9, 0xFF,                         // C016 LDA #$FF
3910            0x8D, 0x07, 0x20,                   // C018 STA $2007  (8x low plane)
3911            0xCA, 0xD0, 0xFA,                   // C01B DEX / BNE $C018
3912            // --- palette: $3F00 = $0F (black), $3F01 = $16 (red)
3913            0xA9, 0x3F, 0x8D, 0x06, 0x20,       // C01E LDA #$3F / STA $2006
3914            0xA9, 0x00, 0x8D, 0x06, 0x20,       // C023 LDA #$00 / STA $2006  -> $3F00
3915            0xA9, 0x0F, 0x8D, 0x07, 0x20,       // C028 LDA #$0F / STA $2007
3916            0xA9, 0x16, 0x8D, 0x07, 0x20,       // C02D LDA #$16 / STA $2007
3917            // --- nametable + attributes $2000..$23FF = $01
3918            0xA9, 0x20, 0x8D, 0x06, 0x20,       // C032 LDA #$20 / STA $2006
3919            0xA9, 0x00, 0x8D, 0x06, 0x20,       // C037 LDA #$00 / STA $2006  -> $2000
3920            0xA0, 0x04,                         // C03C LDY #$04     (4 x 256)
3921            0xA2, 0x00,                         // C03E LDX #$00
3922            0xA9, 0x01,                         // C040 LDA #$01
3923            0x8D, 0x07, 0x20,                   // C042 STA $2007
3924            0xCA, 0xD0, 0xFA,                   // C045 DEX / BNE $C042
3925            0x88, 0xD0, 0xF3,                   // C048 DEY / BNE $C03E
3926            // --- enable BG: $2000 = $00 (BG pattern table $0000, NT $2000),
3927            //     $2001 = $08 (show BG, but NOT the leftmost 8 px)
3928            0xA9, 0x00, 0x8D, 0x00, 0x20,       // C04B LDA #$00 / STA $2000
3929            0xA9, 0x08, 0x8D, 0x01, 0x20,       // C050 LDA #$08 / STA $2001
3930            0x4C, 0x55, 0xC0,                   // C055 JMP $C055  (spin)
3931        ];
3932        prg[..code.len()].copy_from_slice(code);
3933        let len = 16 * 1024;
3934        prg[len - 4] = 0x00; // reset vector -> $C000
3935        prg[len - 3] = 0xC0;
3936
3937        let mut nes = Nes::from_rom(&bytes).expect("parse");
3938        // Enough frames for the delay loop, then the setup loops, to complete
3939        // and for rendering to be running steadily.
3940        for _ in 0..16 {
3941            let _ = nes.run_frame();
3942        }
3943        assert!(nes.pixel_provenance().is_none(), "off by default");
3944        nes.set_pixel_provenance(true);
3945        let _ = nes.run_frame();
3946
3947        let prov = nes.pixel_provenance().expect("armed");
3948
3949        // Pixel (16, 0) sits in tile column 2 of row 0, i.e. nametable $2002.
3950        let rec = prov.get(16, 0).expect("on-screen");
3951        assert_eq!(
3952            rec.layer,
3953            PixelLayer::Background,
3954            "the all-$FF pattern makes every BG pixel opaque"
3955        );
3956        assert_eq!(
3957            rec.nt_addr, 0x2002,
3958            "the record must name the tile ON SCREEN at x=16; `v` at emit time \
3959             has already advanced two tiles past it, so a value near $2004 here \
3960             would mean the cascade is not tracking the shifters"
3961        );
3962        assert_eq!(rec.at_addr, 0x23C0, "tile (2,0) -> attribute byte 0");
3963        assert_eq!(rec.bg_idx, 1, "low plane $FF, high plane $00 -> index 1");
3964        assert_eq!(rec.palette_addr, 0x3F01, "palette group 0, index 1");
3965        assert_eq!(rec.palette_index, 1);
3966        assert_eq!(rec.color, 0x16, "the red we wrote to $3F01");
3967        assert_eq!(rec.scanline, 0);
3968        assert_eq!(rec.dot, 17, "screen X is dot - 1");
3969        assert_eq!(
3970            rec.sprite_slot,
3971            rustynes_ppu::SPRITE_SLOT_NONE,
3972            "no sprites in this ROM"
3973        );
3974        // Fine-Y is 2, not 0, and that is correct: the ROM sets the scroll only
3975        // via `$2006 = $20, $00`, which loads `v = t = $2000` — and bits 12-14 of
3976        // a VRAM address ARE the fine-Y field, so `$2000` means fine-Y = 2. A ROM
3977        // that wanted row 0 would have written `$2005` afterwards. The record
3978        // reports what the hardware is actually displaying.
3979        assert_eq!(rec.fine_y, 2, "$2006 = $2000 puts fine-Y at 2");
3980        // Tile $01 base $0010, plus fine-Y 2.
3981        assert_eq!(rec.pattern_addr, 0x0012);
3982
3983        // The cascade advances ONE TILE PER GROUP across the scanline — it is
3984        // not a single address held for the whole line, and not skewed by the
3985        // two dummy nametable fetches at dots 337-340.
3986        for (x, want_nt) in [(0usize, 0x2000u16), (8, 0x2001), (24, 0x2003), (40, 0x2005)] {
3987            assert_eq!(
3988                prov.get(x, 0).expect("on-screen").nt_addr,
3989                want_nt,
3990                "tile column at x={x}"
3991            );
3992        }
3993
3994        // Off-screen queries answer `None` rather than clamping to a pixel the
3995        // caller did not ask about.
3996        assert_eq!(prov.get(256, 0), None);
3997        assert_eq!(prov.get(0, 240), None);
3998
3999        nes.set_pixel_provenance(false);
4000        assert!(nes.pixel_provenance().is_none());
4001    }
4002
4003    /// The two halves compose: provenance gives the palette index, attribution
4004    /// gives the instruction that wrote it. This is the end-to-end claim the
4005    /// feature exists to support.
4006    #[cfg(feature = "debug-hooks")]
4007    #[test]
4008    fn provenance_and_attribution_compose_into_a_causal_chain() {
4009        // Minimal ROM: set $3F00 (backdrop) to a known color from a known PC,
4010        // then spin. Rendering stays off, so every pixel is the backdrop and the
4011        // chain is unambiguous.
4012        //   C000: LDA #$3F / STA $2006
4013        //   C005: LDA #$00 / STA $2006      -> v = $3F00
4014        //   C00A: LDA #$21 / STA $2007      <-- the palette write
4015        //   C00F: JMP $C000
4016        const STA_2007_PC: u16 = 0xC00C;
4017        const COLOR: u8 = 0x21;
4018
4019        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
4020        bytes[0..4].copy_from_slice(b"NES\x1A");
4021        bytes[4] = 1;
4022        bytes[5] = 0;
4023        let prg = &mut bytes[16..16 + 16 * 1024];
4024        prg[0..18].copy_from_slice(&[
4025            0xA9, 0x3F, 0x8D, 0x06, 0x20, // LDA #$3F / STA $2006
4026            0xA9, 0x00, 0x8D, 0x06, 0x20, // LDA #$00 / STA $2006
4027            0xA9, COLOR, 0x8D, 0x07, 0x20, // LDA #$21 / STA $2007
4028            0x4C, 0x00, 0xC0, // JMP $C000
4029        ]);
4030        let len = 16 * 1024;
4031        prg[len - 4] = 0x00;
4032        prg[len - 3] = 0xC0;
4033
4034        let mut nes = Nes::from_rom(&bytes).expect("parse");
4035        let _ = nes.run_frame();
4036        nes.set_pixel_provenance(true);
4037        nes.set_write_attribution(true);
4038        for _ in 0..3 {
4039            let _ = nes.run_frame();
4040        }
4041
4042        // Step 1: which palette entry produced this pixel?
4043        let rec = nes
4044            .pixel_provenance()
4045            .and_then(|p| p.get(100, 100))
4046            .expect("armed and on-screen");
4047        assert_eq!(rec.layer, rustynes_ppu::PixelLayer::Backdrop);
4048        assert_eq!(rec.palette_index, 0, "the universal backdrop");
4049        assert_eq!(rec.color, COLOR);
4050
4051        // Step 2: who wrote that palette entry?
4052        let who = nes
4053            .write_attribution()
4054            .and_then(|a| a.palette(rec.palette_index as usize))
4055            .expect("the STA $2007 wrote it");
4056        assert_eq!(
4057            who.pc, STA_2007_PC,
4058            "the chain closes: pixel -> palette entry -> writing instruction"
4059        );
4060        assert_eq!(who.value, COLOR);
4061    }
4062
4063    /// An OAM DMA burst moves 256 bytes but has exactly one cause. The store
4064    /// must say so — attributing all 256 to the `STA $4014` that triggered them
4065    /// rather than inventing a per-byte PC that no instruction ever had.
4066    #[cfg(feature = "debug-hooks")]
4067    #[test]
4068    fn oam_dma_attributes_all_256_bytes_to_the_triggering_store() {
4069        // NROM at $C000:
4070        //   C000: A9 02     LDA #$02
4071        //   C002: 8D 14 40  STA $4014   <-- one instruction, 256 OAM bytes
4072        //   C005: 4C 00 C0  JMP $C000
4073        //
4074        // `$4014` is not subject to the PPU's post-reset write-mask window, so
4075        // this lands on the first pass; the loop just keeps it landing.
4076        const STA_4014_PC: u16 = 0xC002;
4077
4078        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
4079        bytes[0..4].copy_from_slice(b"NES\x1A");
4080        bytes[4] = 1;
4081        bytes[5] = 0;
4082        let prg = &mut bytes[16..16 + 16 * 1024];
4083        prg[0..8].copy_from_slice(&[
4084            0xA9, 0x02, // LDA #$02
4085            0x8D, 0x14, 0x40, // STA $4014
4086            0x4C, 0x00, 0xC0, // JMP $C000
4087        ]);
4088        let len = 16 * 1024;
4089        prg[len - 4] = 0x00;
4090        prg[len - 3] = 0xC0;
4091
4092        let mut nes = Nes::from_rom(&bytes).expect("parse");
4093        // The first `run_frame` after power-on returns on the already-latched
4094        // frame-complete flag, executing almost no instructions — the same
4095        // warm-up the event-viewer tests need. Arm AFTER it, so the assertion
4096        // below is about a frame that actually ran code.
4097        let _ = nes.run_frame();
4098        nes.set_write_attribution(true);
4099        let _ = nes.run_frame();
4100
4101        let attrib = nes.write_attribution().expect("armed");
4102        for idx in 0..=u8::MAX {
4103            let rec = attrib
4104                .oam(idx)
4105                .unwrap_or_else(|| panic!("OAM byte {idx} unattributed after a full DMA burst"));
4106            assert_eq!(
4107                rec.pc, STA_4014_PC,
4108                "OAM byte {idx} must name the STA $4014, not a synthesized PC"
4109            );
4110        }
4111    }
4112
4113    /// A save-state restore must invalidate attribution: the restored bytes were
4114    /// not written by anything this session ran, so the honest answer is "no
4115    /// record", not the PC that wrote that offset on the abandoned timeline.
4116    #[cfg(feature = "debug-hooks")]
4117    #[test]
4118    fn write_attribution_is_invalidated_by_restore() {
4119        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
4120        bytes[0..4].copy_from_slice(b"NES\x1A");
4121        bytes[4] = 1;
4122        bytes[5] = 0;
4123        let prg = &mut bytes[16..16 + 16 * 1024];
4124        // Same looping `$2006`/`$2007` ROM as the test above; see its comment for
4125        // why it loops and why three frames are needed.
4126        prg[0..18].copy_from_slice(&[
4127            0xA9, 0x21, 0x8D, 0x06, 0x20, 0xA9, 0x08, 0x8D, 0x06, 0x20, 0xA9, 0x5A, 0x8D, 0x07,
4128            0x20, 0x4C, 0x00, 0xC0,
4129        ]);
4130        let len = 16 * 1024;
4131        prg[len - 4] = 0x00;
4132        prg[len - 3] = 0xC0;
4133
4134        let mut nes = Nes::from_rom(&bytes).expect("parse");
4135        nes.set_write_attribution(true);
4136        for _ in 0..3 {
4137            let _ = nes.run_frame();
4138        }
4139        let off = 0x0108usize;
4140        assert!(
4141            nes.write_attribution().and_then(|a| a.ciram(off)).is_some(),
4142            "precondition: the write was attributed"
4143        );
4144
4145        let snap = nes.snapshot();
4146        nes.restore(&snap).expect("round-trip");
4147        assert!(
4148            nes.write_attribution().is_some(),
4149            "the store stays armed across a restore"
4150        );
4151        assert_eq!(
4152            nes.write_attribution().and_then(|a| a.ciram(off)),
4153            None,
4154            "but its records are dropped — they describe a timeline that the \
4155             restore replaced"
4156        );
4157    }
4158
4159    #[cfg(feature = "debug-hooks")]
4160    #[test]
4161    fn event_viewer_records_ppu_reads() {
4162        use crate::bus::EventKind;
4163        // A tiny NROM that loops `LDA $2002 ; JMP $C000`, generating a PPU
4164        // STATUS read ($2002) every iteration (v1.5.0 Workstream A2 read tap).
4165        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
4166        bytes[0..4].copy_from_slice(b"NES\x1A");
4167        bytes[4] = 1; // 1x16KB PRG
4168        let prg = &mut bytes[16..16 + 16 * 1024];
4169        // $C000: LDA $2002 ; JMP $C000
4170        prg[0..6].copy_from_slice(&[0xAD, 0x02, 0x20, 0x4C, 0x00, 0xC0]);
4171        let len = 16 * 1024;
4172        prg[len - 4] = 0x00;
4173        prg[len - 3] = 0xC0;
4174
4175        let mut nes = Nes::from_rom(&bytes).expect("parse");
4176        let _ = nes.run_frame();
4177        nes.set_event_logging(true);
4178        let _ = nes.run_frame();
4179        let evs = nes.events();
4180        assert!(
4181            evs.iter()
4182                .any(|e| e.kind == EventKind::PpuRead && e.addr == 0x2002),
4183            "the LDA $2002 loop produces PPU reads"
4184        );
4185        assert!(
4186            evs.iter()
4187                .all(|e| e.kind.is_read() == (e.kind == EventKind::PpuRead)),
4188            "is_read is true only for PpuRead"
4189        );
4190        nes.set_event_logging(false);
4191    }
4192
4193    #[cfg(feature = "debug-hooks")]
4194    #[test]
4195    fn event_breakpoint_fires_on_armed_category_only() {
4196        use crate::EventBpKind;
4197        // The same `LDA #$00 ; STA $2000 ; JMP $C000` loop — it issues a PPU
4198        // write every iteration but never an APU write or interrupt service.
4199        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
4200        bytes[0..4].copy_from_slice(b"NES\x1A");
4201        bytes[4] = 1;
4202        bytes[5] = 0;
4203        let prg = &mut bytes[16..16 + 16 * 1024];
4204        prg[0..8].copy_from_slice(&[0xA9, 0x00, 0x8D, 0x00, 0x20, 0x4C, 0x00, 0xC0]);
4205        let len = 16 * 1024;
4206        prg[len - 4] = 0x00;
4207        prg[len - 3] = 0xC0;
4208
4209        let mut nes = Nes::from_rom(&bytes).expect("parse");
4210        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
4211
4212        // Default: nothing armed, no hits.
4213        assert_eq!(nes.event_breakpoints(), 0, "disarmed by default");
4214        let _ = nes.run_frame();
4215        assert_eq!(nes.take_event_break_hit(), None, "no hit while disarmed");
4216
4217        // Arm an UNRELATED category (APU write): the $2000 loop never trips it.
4218        nes.set_event_breakpoints(EventBpKind::ApuWrite.bit());
4219        let _ = nes.run_frame();
4220        assert_eq!(
4221            nes.take_event_break_hit(),
4222            None,
4223            "wrong category does not fire"
4224        );
4225
4226        // Arm PPU write: the very next frame must latch a hit with sane context.
4227        nes.set_event_breakpoints(EventBpKind::PpuWrite.bit());
4228        let _ = nes.run_frame();
4229        let hit = nes.take_event_break_hit().expect("PPU write fires");
4230        assert_eq!(hit.kind, EventBpKind::PpuWrite);
4231        assert_eq!(hit.addr, 0x2000, "the STA $2000 target");
4232        assert!(hit.dot <= 340, "dot in range");
4233        assert!(
4234            hit.scanline >= -1 && hit.scanline <= 260,
4235            "scanline in range"
4236        );
4237        // Cleared on read + only one (first) hit recorded per frame.
4238        assert_eq!(nes.take_event_break_hit(), None, "cleared on read");
4239
4240        // Disarming all stops it firing again.
4241        nes.set_event_breakpoints(0);
4242        let _ = nes.run_frame();
4243        assert_eq!(nes.take_event_break_hit(), None, "disarmed: silent");
4244    }
4245
4246    #[cfg(feature = "debug-hooks")]
4247    #[test]
4248    fn event_bp_kind_mask_and_labels_are_distinct() {
4249        use crate::EventBpKind;
4250        let all = EventBpKind::all();
4251        // Every category has a distinct bit and a non-empty label.
4252        let mut seen = 0u16;
4253        for k in all {
4254            assert_eq!(seen & k.bit(), 0, "{} bit collides", k.label());
4255            seen |= k.bit();
4256            assert!(!k.label().is_empty());
4257        }
4258        assert_eq!(seen.count_ones() as usize, all.len(), "11 distinct bits");
4259    }
4260
4261    #[test]
4262    fn debug_snapshots_are_read_only() {
4263        // T-53-002+ -- inspection must not advance emulator state.
4264        let rom = synth_nrom(16, 8);
4265        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4266        for _ in 0..2 {
4267            nes.run_frame();
4268        }
4269        let cycle_before = nes.cycle();
4270        let _cpu = nes.cpu_snapshot();
4271        let _ppu = nes.ppu_snapshot();
4272        let _apu = nes.apu_snapshot();
4273        let _oam = nes.oam();
4274        let _pal = nes.palette_ram();
4275        let _mapper = nes.mapper_info();
4276        // cpu_bus_peek and pattern_table_rgba take &mut so we exercise them too.
4277        let _byte = nes.cpu_bus_peek(0xC000);
4278        let _byte = nes.ppu_bus_peek(0x2000);
4279        let pt = nes.pattern_table_rgba(0);
4280        assert_eq!(pt.len(), 128 * 128 * 4, "pattern table RGBA size");
4281        let nt = nes.nametable_rgba(0);
4282        assert_eq!(nt.len(), 256 * 240 * 4, "nametable RGBA size");
4283        assert_eq!(nes.cycle(), cycle_before, "inspection MUST NOT tick CPU");
4284    }
4285
4286    #[test]
4287    fn disassembler_round_trips_against_cpu_bus() {
4288        // Walk a small synthesized program through the disassembler.
4289        let rom = synth_nrom(16, 8);
4290        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4291        let pc = nes.cpu().pc;
4292        // Take a fixed-size byte window via the peek API first; disasm
4293        // wants a `Fn`, and our peek is `FnMut`.
4294        let mut buf = [0u8; 16];
4295        for (i, b) in buf.iter_mut().enumerate() {
4296            *b = nes.cpu_bus_peek(pc.wrapping_add(u16::try_from(i).unwrap_or(0)));
4297        }
4298        let lines = rustynes_cpu::disassemble_at(
4299            |a| {
4300                let off = a.wrapping_sub(pc) as usize;
4301                buf.get(off).copied().unwrap_or(0)
4302            },
4303            pc,
4304            4,
4305        );
4306        assert_eq!(lines.len(), 4);
4307        // First instruction is JMP $C000 (0x4C 0x00 0xC0).
4308        assert_eq!(lines[0].addr, pc);
4309        assert_eq!(lines[0].mnemonic, "JMP");
4310    }
4311
4312    #[test]
4313    fn rom_sha256_is_deterministic() {
4314        let rom = synth_nrom(16, 8);
4315        let nes_a = Nes::from_rom(&rom).unwrap();
4316        let nes_b = Nes::from_rom(&rom).unwrap();
4317        assert_eq!(nes_a.rom_sha256(), nes_b.rom_sha256());
4318        // Different ROM -> different hash.
4319        let mut other = synth_nrom(16, 8);
4320        other[0x10] = 0x99;
4321        let nes_c = Nes::from_rom(&other).unwrap();
4322        assert_ne!(nes_a.rom_sha256(), nes_c.rom_sha256());
4323    }
4324
4325    #[test]
4326    fn thumbnail_has_expected_dimensions() {
4327        let rom = synth_nrom(16, 8);
4328        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4329        nes.run_frame();
4330        let thumb = nes.thumbnail();
4331        assert_eq!(thumb.len(), save_state::THUMBNAIL_LEN);
4332        assert_eq!(
4333            save_state::THUMBNAIL_LEN,
4334            save_state::THUMBNAIL_WIDTH * save_state::THUMBNAIL_HEIGHT * 4
4335        );
4336    }
4337
4338    #[test]
4339    fn snapshot_includes_thumbnail_section_extractable() {
4340        let rom = synth_nrom(16, 8);
4341        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4342        for _ in 0..2 {
4343            nes.run_frame();
4344        }
4345        let blob = nes.snapshot();
4346        let extracted = Nes::extract_thumbnail(&blob).expect("blob is valid");
4347        let thumb = extracted.expect("snapshot must include THM section");
4348        assert_eq!(thumb.len(), save_state::THUMBNAIL_LEN);
4349        // Round-trip: thumbnail bytes must match the live framebuffer
4350        // downsample taken at the same cycle.
4351        assert_eq!(thumb, nes.thumbnail());
4352    }
4353
4354    #[test]
4355    fn snapshot_round_trip_still_works_with_thumbnail() {
4356        // ADR-0003 invariant: adding THM must not perturb deterministic
4357        // restore. Re-runs the snapshot_round_trip test with the new
4358        // thumbnail section present in the blob.
4359        let rom = synth_nrom(16, 8);
4360        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4361        for _ in 0..4 {
4362            nes.run_frame();
4363        }
4364        let cycle = nes.cycle();
4365        let fb_hash_before = fnv_hash(nes.framebuffer());
4366        let blob = nes.snapshot();
4367        for _ in 0..4 {
4368            nes.run_frame();
4369        }
4370        assert_ne!(nes.cycle(), cycle);
4371        nes.restore(&blob)
4372            .expect("restore must succeed with THM present");
4373        assert_eq!(nes.cycle(), cycle);
4374        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash_before);
4375    }
4376
4377    #[test]
4378    fn restore_accepts_v0_9_0_blob_without_thumbnail() {
4379        // ADR-0003 invariant: older slot files without a THM section must
4380        // still restore. Simulate a v0.9.0 blob by stripping the THM
4381        // section out of a freshly-emitted snapshot.
4382        let rom = synth_nrom(16, 8);
4383        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
4384        for _ in 0..3 {
4385            nes.run_frame();
4386        }
4387        let cycle = nes.cycle();
4388        let fb_hash = fnv_hash(nes.framebuffer());
4389        let with_thumb = nes.snapshot();
4390
4391        // Reconstruct a blob without the THM section.
4392        let (_h, body_off) = save_state::parse_header(&with_thumb).unwrap();
4393        let mut without_thumb = with_thumb[..body_off].to_vec();
4394        for s in save_state::SectionIter::new(&with_thumb[body_off..]) {
4395            let s = s.unwrap();
4396            if s.tag == save_state::tag::THM {
4397                continue;
4398            }
4399            save_state::write_section(&mut without_thumb, s.tag, s.version, s.body);
4400        }
4401        assert!(without_thumb.len() < with_thumb.len());
4402        // Extract on the v0.9.0-shaped blob returns None for the thumbnail.
4403        let extracted = Nes::extract_thumbnail(&without_thumb).unwrap();
4404        assert!(extracted.is_none(), "v0.9.0 blob has no THM section");
4405
4406        // Drift then restore from the v0.9.0-shaped blob.
4407        for _ in 0..2 {
4408            nes.run_frame();
4409        }
4410        nes.restore(&without_thumb)
4411            .expect("v0.9.0 blob must restore");
4412        assert_eq!(nes.cycle(), cycle);
4413        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash);
4414    }
4415
4416    /// Build a minimal NSF (3 songs) whose `init` enables all APU channels and
4417    /// programs a steady pulse-1 tone, and whose `play` is a bare `RTS`. Loaded
4418    /// at $8000; init=$8000, play=$800C.
4419    fn synth_tone_nsf() -> Vec<u8> {
4420        let mut f = vec![0u8; 0x80];
4421        f[0..5].copy_from_slice(b"NESM\x1A");
4422        f[0x05] = 1; // version
4423        f[0x06] = 3; // total songs
4424        f[0x07] = 1; // starting song
4425        f[0x08] = 0x00;
4426        f[0x09] = 0x80; // load $8000
4427        f[0x0A] = 0x00;
4428        f[0x0B] = 0x80; // init $8000
4429        f[0x0C] = 0x0C;
4430        f[0x0D] = 0x80; // play $800C
4431        let program: &[u8] = &[
4432            // init ($8000): enable channels + a constant-volume pulse-1 tone.
4433            0xA9, 0x0F, 0x8D, 0x15, 0x40, // LDA #$0F; STA $4015
4434            0xA9, 0xBF, 0x8D, 0x00, 0x40, // LDA #$BF; STA $4000 (duty/const vol)
4435            0x60, // RTS
4436            0xA0, // padding so play lands at $800C
4437            // play ($800C):
4438            0x60, // RTS
4439        ];
4440        f.extend_from_slice(program);
4441        f
4442    }
4443
4444    #[test]
4445    fn nsf_constructs_runs_and_selects_tracks() {
4446        let mut nes = Nes::from_nsf(&synth_tone_nsf()).expect("valid nsf builds");
4447        assert_eq!(nes.nsf_song_count(), 3);
4448        assert_eq!(nes.nsf_current_song(), 0);
4449
4450        // Run several frames: the driver's reset vector runs `init` (enabling
4451        // the APU + pulse-1), then vblank NMI calls `play` each frame. Audio
4452        // must be produced and the run must not panic.
4453        let mut produced = 0usize;
4454        for _ in 0..8 {
4455            nes.run_frame();
4456            produced += nes.drain_audio().len();
4457        }
4458        assert!(produced > 0, "NSF playback must produce audio samples");
4459
4460        // Track select clamps + restarts on the new song.
4461        nes.nsf_set_song(2);
4462        assert_eq!(nes.nsf_current_song(), 2);
4463        nes.run_frame();
4464        nes.nsf_set_song(99);
4465        assert_eq!(nes.nsf_current_song(), 2, "clamped to last song");
4466    }
4467
4468    /// A minimal non-60-Hz NSF whose `play` routine increments zero-page `$00`,
4469    /// so a test can count how many times the cycle-timer IRQ drove `play`.
4470    fn synth_counting_nsf_50hz() -> Vec<u8> {
4471        let mut f = vec![0u8; 0x80];
4472        f[0..5].copy_from_slice(b"NESM\x1A");
4473        f[0x05] = 1; // version
4474        f[0x06] = 1; // 1 song
4475        f[0x07] = 1; // starting song
4476        f[0x08] = 0x00;
4477        f[0x09] = 0x80; // load $8000
4478        f[0x0A] = 0x00;
4479        f[0x0B] = 0x80; // init $8000
4480        f[0x0C] = 0x06;
4481        f[0x0D] = 0x80; // play $8006
4482        // NTSC play-speed divider $6E-$6F = 20000 µs (~50 Hz) — a non-standard
4483        // rate that selects the cycle-timer IRQ driver instead of vblank-NMI.
4484        f[0x6E] = 0x20;
4485        f[0x6F] = 0x4E; // 0x4E20 = 20000
4486        let program: &[u8] = &[
4487            // init ($8000): enable APU channels, RTS. (6 bytes)
4488            0xA9, 0x0F, 0x8D, 0x15, 0x40, 0x60, // play ($8006): INC $00; RTS
4489            0xE6, 0x00, 0x60,
4490        ];
4491        f.extend_from_slice(program);
4492        f
4493    }
4494
4495    #[test]
4496    fn nsf_nonstandard_rate_drives_play_via_timer_irq() {
4497        let mut nes = Nes::from_nsf(&synth_counting_nsf_50hz()).expect("valid nsf");
4498        // 12 NTSC frames ≈ 0.2 s. A ~50 Hz play-timer IRQ must fire `play`
4499        // (INC $00) several times — proving the cycle-timer IRQ path works
4500        // end-to-end through `run_frame` — but FEWER than the 12 frames, since
4501        // 50 Hz is slower than the 60 Hz once-per-vblank rate.
4502        for _ in 0..12 {
4503            nes.run_frame();
4504        }
4505        let calls = nes.cpu_bus_peek(0x0000);
4506        assert!(
4507            calls > 0,
4508            "timer IRQ must drive `play` at the non-standard rate"
4509        );
4510        assert!(
4511            calls < 12,
4512            "50 Hz must call `play` fewer times than 60 Hz frames (got {calls})"
4513        );
4514    }
4515
4516    /// v2.3.7: `$4014` and `$4016` must actually be attributed.
4517    ///
4518    /// Both sit inside the `$4000-$4017` window the audio-provenance table
4519    /// reserves slots for, and both are handled entirely on the bus — `Bus::write`
4520    /// routes only `$4000-$4013 | $4015 | $4017` to `Apu::write_register`, which
4521    /// is where attribution was recorded. So the two reserved slots could never
4522    /// be filled, while `docs/audio-provenance.md` and the `REG_COUNT` doc
4523    /// comment both stated they were "tracked anyway".
4524    ///
4525    /// Caught by the Antigravity reviewer on PR #404. This test fails without
4526    /// `Apu::record_bus_handled_register_write` being called from both bus arms:
4527    /// remove either call and the corresponding `get()` returns `None`.
4528    #[cfg(feature = "debug-hooks")]
4529    #[test]
4530    fn bus_handled_apu_window_writes_are_attributed() {
4531        // `Bus::write` is the ordinary CPU write path both addresses travel.
4532        use rustynes_cpu::Bus as _;
4533
4534        let mut nes = Nes::from_rom(&synth_nrom(16, 8)).expect("nrom builds");
4535        nes.set_audio_provenance(true);
4536        assert!(nes.audio_provenance_armed(), "premise: armed");
4537
4538        // Pin a known attribution context, then write both bus-handled
4539        // addresses through the ordinary CPU write path.
4540        nes.bus.apu.set_attrib_context(0xC123, 4_242);
4541        nes.bus.write(0x4014, 0x02); // OAM DMA page
4542        nes.bus.write(0x4016, 0x01); // controller strobe
4543
4544        let attrib = nes
4545            .bus
4546            .apu
4547            .register_attribution()
4548            .expect("armed, so the table exists");
4549
4550        let dma = attrib
4551            .get(0x4014)
4552            .expect("$4014 must be attributed — it is inside the reserved window");
4553        assert_eq!(dma.pc, 0xC123, "$4014 attributed to the wrong instruction");
4554        assert_eq!(dma.value, 0x02, "$4014 recorded the wrong value");
4555
4556        let strobe = attrib
4557            .get(0x4016)
4558            .expect("$4016 must be attributed — it is inside the reserved window");
4559        assert_eq!(
4560            strobe.pc, 0xC123,
4561            "$4016 attributed to the wrong instruction"
4562        );
4563        assert_eq!(strobe.value, 0x01, "$4016 recorded the wrong value");
4564
4565        // A genuine APU register still works — the new path is additive, not a
4566        // replacement for the one inside `write_register`.
4567        nes.bus.write(0x4015, 0x0F);
4568        assert_eq!(
4569            attrib_value(&nes, 0x4015),
4570            Some(0x0F),
4571            "the normal write_register attribution path must be unaffected"
4572        );
4573    }
4574
4575    /// Small helper so the assertion above reads as one line.
4576    #[cfg(feature = "debug-hooks")]
4577    fn attrib_value(nes: &Nes, addr: u16) -> Option<u8> {
4578        nes.bus
4579            .apu
4580            .register_attribution()
4581            .and_then(|a| a.get(addr))
4582            .map(|w| w.value)
4583    }
4584
4585    #[test]
4586    fn nsf_song_apis_are_inert_on_a_cartridge() {
4587        let mut nes = Nes::from_rom(&synth_nrom(16, 8)).expect("nrom builds");
4588        assert_eq!(nes.nsf_song_count(), 0);
4589        assert_eq!(nes.nsf_current_song(), 0);
4590        nes.nsf_set_song(1); // no-op, must not panic or reset spuriously
4591        assert_eq!(nes.nsf_current_song(), 0);
4592    }
4593
4594    /// v2.3.6: the beam-relative Zapper model is ON by default.
4595    ///
4596    /// It shipped OFF in v2.2.3-v2.3.5 on the reasoning that no light-gun test
4597    /// ROM could adjudicate it and the supported titles were satisfied either
4598    /// way. The second half was false: under the frame-granular model *Duck
4599    /// Hunt* receives its "dark frame then bright frame" probe inverted and can
4600    /// never register a hit. See `LockstepBus::set_zapper_temporal_light`.
4601    #[test]
4602    fn zapper_temporal_light_is_on_by_default() {
4603        let mut nes = Nes::from_rom(&synth_nrom(16, 8)).expect("nrom builds");
4604        assert!(
4605            nes.zapper_temporal_light(),
4606            "the beam-relative model must default ON from v2.3.6"
4607        );
4608        nes.set_zapper(1, 100, 120, false);
4609        // Toggling it off and back on must restore the default exactly.
4610        nes.set_zapper_temporal_light(false);
4611        assert!(!nes.zapper_temporal_light());
4612        nes.set_zapper_temporal_light(true);
4613        assert!(nes.zapper_temporal_light());
4614    }
4615
4616    /// The timeline counter is session-local: a save state neither carries it nor
4617    /// restores it, and loading one ADVANCES the live counter instead.
4618    ///
4619    /// Documented on the field, and untestable by `snapshot_schema_audit` for the
4620    /// very reason that makes it true -- the counter lives outside the snapshot,
4621    /// so that audit cannot see it. Pinned here instead, because the two ways
4622    /// serializing it would be wrong are both silent: loading the same slot twice
4623    /// would restore the same generation twice and a consumer would miss the
4624    /// second load, and a value from another session means nothing in this one.
4625    #[test]
4626    fn the_timeline_counter_is_session_local_and_advances_on_restore() {
4627        let rom = synth_nrom(16, 8);
4628        let mut nes = Nes::from_rom(&rom).expect("parse");
4629        nes.run_frame();
4630        let state = nes.snapshot();
4631        let before = nes.timeline_generation();
4632
4633        nes.restore(&state).expect("restore");
4634        let after_first = nes.timeline_generation();
4635        assert!(
4636            after_first > before,
4637            "a restore must advance the timeline counter: {before} -> {after_first}"
4638        );
4639
4640        // The SECOND load of the SAME slot must advance it again. This is the
4641        // assertion that would fail if the counter were serialized -- the restored
4642        // value would be identical both times and a consumer comparing against its
4643        // last-seen value would never notice the second load.
4644        nes.restore(&state).expect("restore again");
4645        assert!(
4646            nes.timeline_generation() > after_first,
4647            "reloading the same slot must still register as a new timeline"
4648        );
4649
4650        // And a snapshot taken now must not encode it: a fresh `Nes` restored from
4651        // this state starts its own count rather than adopting ours.
4652        let mut fresh = Nes::from_rom(&rom).expect("parse");
4653        assert_eq!(
4654            fresh.timeline_generation(),
4655            0,
4656            "a fresh Nes must start at zero"
4657        );
4658        fresh.restore(&nes.snapshot()).expect("restore into fresh");
4659        assert_eq!(
4660            fresh.timeline_generation(),
4661            1,
4662            "the fresh instance must count its own restores, not inherit a stored value"
4663        );
4664    }
4665}