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}
177
178impl Nes {
179    /// Returns a reference to the internal WRAM.
180    pub fn wram(&self) -> &[u8] {
181        self.bus.ram.as_ref()
182    }
183
184    /// Returns a mutable reference to the internal WRAM.
185    pub fn wram_mut(&mut self) -> &mut [u8] {
186        self.bus.ram.as_mut()
187    }
188
189    /// Returns a reference to the cartridge SRAM (if any).
190    pub fn sram(&self) -> &[u8] {
191        self.bus.mapper.sram()
192    }
193
194    /// Returns a mutable reference to the cartridge SRAM (if any).
195    pub fn sram_mut(&mut self) -> &mut [u8] {
196        self.bus.mapper.sram_mut()
197    }
198
199    /// Returns a reference to the internal VRAM (nametables).
200    pub fn vram(&self) -> &[u8] {
201        self.bus.ppu.vram_ref()
202    }
203
204    /// Returns a mutable reference to the internal VRAM (nametables).
205    pub fn vram_mut(&mut self) -> &mut [u8] {
206        self.bus.ppu.vram_mut()
207    }
208
209    /// Build a new emulator from raw ROM bytes (iNES 1.0 or NES 2.0).
210    ///
211    /// # Errors
212    ///
213    /// Returns the underlying [`RomError`] if the bytes don't parse.
214    pub fn from_rom(bytes: &[u8]) -> Result<Self, RomError> {
215        let mut bus = LockstepBus::new(bytes)?;
216        // Cold-boot path: `Cpu::power_on()` seeds `S=$00`; the subsequent
217        // `reset()`'s `S -= 3` (wrapping) lands at `$FD`, matching Mesen2's
218        // power-up state. See `docs/audit/session-13-cpu-boot-fix-2026-05-21.md`.
219        let mut cpu = Cpu::power_on();
220        cpu.reset(&mut bus);
221        Ok(Self {
222            cpu,
223            bus,
224            rom_sha256: sha256_of(bytes),
225            rewind: None,
226            rewind_capture_enabled: true,
227            rewind_snap_buf: Vec::new(),
228            #[cfg(feature = "cpu-boot-trace")]
229            cpu_boot_trace: None,
230            #[cfg(feature = "debug-hooks")]
231            breakpoints: Vec::new(),
232            #[cfg(feature = "debug-hooks")]
233            breakpoints_enabled: true,
234            #[cfg(feature = "debug-hooks")]
235            break_hit: None,
236            #[cfg(feature = "debug-hooks")]
237            skip_breakpoint_at: None,
238            #[cfg(feature = "debug-hooks")]
239            trace: alloc::collections::VecDeque::new(),
240            #[cfg(feature = "debug-hooks")]
241            trace_enabled: false,
242            #[cfg(feature = "debug-hooks")]
243            exec_log: Vec::new(),
244            #[cfg(feature = "debug-hooks")]
245            exec_logging: false,
246        })
247    }
248
249    /// Build an emulator with an explicit audio sample rate (the rate the
250    /// CPAL stream is opened at).
251    ///
252    /// # Errors
253    ///
254    /// Returns the underlying [`RomError`] if the bytes don't parse.
255    pub fn from_rom_with_sample_rate(bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
256        let mut bus = LockstepBus::with_sample_rate(bytes, sample_rate)?;
257        // Cold-boot path: see comment in `from_rom`.
258        let mut cpu = Cpu::power_on();
259        cpu.reset(&mut bus);
260        Ok(Self {
261            cpu,
262            bus,
263            rom_sha256: sha256_of(bytes),
264            rewind: None,
265            rewind_capture_enabled: true,
266            rewind_snap_buf: Vec::new(),
267            #[cfg(feature = "cpu-boot-trace")]
268            cpu_boot_trace: None,
269            #[cfg(feature = "debug-hooks")]
270            breakpoints: Vec::new(),
271            #[cfg(feature = "debug-hooks")]
272            breakpoints_enabled: true,
273            #[cfg(feature = "debug-hooks")]
274            break_hit: None,
275            #[cfg(feature = "debug-hooks")]
276            skip_breakpoint_at: None,
277            #[cfg(feature = "debug-hooks")]
278            trace: alloc::collections::VecDeque::new(),
279            #[cfg(feature = "debug-hooks")]
280            trace_enabled: false,
281            #[cfg(feature = "debug-hooks")]
282            exec_log: Vec::new(),
283            #[cfg(feature = "debug-hooks")]
284            exec_logging: false,
285        })
286    }
287
288    /// Build an emulator from a Famicom Disk System `.fds` disk image and a
289    /// user-supplied 8 KiB BIOS (`disksys.rom`).
290    ///
291    /// The BIOS is never committed to this repo (it is Nintendo IP); the caller
292    /// supplies it (a frontend BIOS prompt is Stage 2). Construction parses the
293    /// disk container, builds the FDS device as the bus's mapper, and runs the
294    /// standard cold-boot reset (the BIOS reset vector at `$FFFC` drives the
295    /// disk-load sequence).
296    ///
297    /// Uses the default 44.1 kHz audio sample rate; use
298    /// [`Nes::from_disk_with_sample_rate`] to pick the rate.
299    ///
300    /// # Errors
301    ///
302    /// Returns the underlying [`RomError`] if the disk image is unparseable or
303    /// the BIOS is not exactly 8 KiB.
304    pub fn from_disk(disk_bytes: &[u8], bios_bytes: &[u8]) -> Result<Self, RomError> {
305        Self::from_disk_with_sample_rate(disk_bytes, bios_bytes, crate::bus::DEFAULT_SAMPLE_RATE)
306    }
307
308    /// Build an FDS emulator with an explicit audio sample rate. See
309    /// [`Nes::from_disk`].
310    ///
311    /// The reported `rom_sha256` hashes the disk-image bytes (not the BIOS), so
312    /// save-states / movies key off the disk the way cartridge builds key off
313    /// the ROM.
314    ///
315    /// # Errors
316    ///
317    /// Returns the underlying [`RomError`] if the disk image is unparseable or
318    /// the BIOS is not exactly 8 KiB.
319    pub fn from_disk_with_sample_rate(
320        disk_bytes: &[u8],
321        bios_bytes: &[u8],
322        sample_rate: u32,
323    ) -> Result<Self, RomError> {
324        let mut bus = LockstepBus::with_disk(disk_bytes, bios_bytes, sample_rate)?;
325        // Cold-boot path: see comment in `from_rom`.
326        let mut cpu = Cpu::power_on();
327        cpu.reset(&mut bus);
328        Ok(Self {
329            cpu,
330            bus,
331            rom_sha256: sha256_of(disk_bytes),
332            rewind: None,
333            rewind_capture_enabled: true,
334            rewind_snap_buf: Vec::new(),
335            #[cfg(feature = "cpu-boot-trace")]
336            cpu_boot_trace: None,
337            #[cfg(feature = "debug-hooks")]
338            breakpoints: Vec::new(),
339            #[cfg(feature = "debug-hooks")]
340            breakpoints_enabled: true,
341            #[cfg(feature = "debug-hooks")]
342            break_hit: None,
343            #[cfg(feature = "debug-hooks")]
344            skip_breakpoint_at: None,
345            #[cfg(feature = "debug-hooks")]
346            trace: alloc::collections::VecDeque::new(),
347            #[cfg(feature = "debug-hooks")]
348            trace_enabled: false,
349            #[cfg(feature = "debug-hooks")]
350            exec_log: Vec::new(),
351            #[cfg(feature = "debug-hooks")]
352            exec_logging: false,
353        })
354    }
355
356    /// Build an emulator that plays a classic NSF (`NESM`) music file.
357    ///
358    /// Only the classic `NESM\x1a` container is supported; `NSFe` and
359    /// expansion-chip audio are documented deferrals.
360    ///
361    /// NSF files carry a ripped NES sound engine plus an `init`/`play` address
362    /// pair, not a PPU program. Construction parses the file, installs a
363    /// [`rustynes_mappers::NsfMapper`] (a synthetic 6502 driver + the program
364    /// image) as the bus's mapper, and runs the standard cold-boot reset — the
365    /// driver's reset vector calls `init` for the starting song, enables vblank
366    /// NMI, and the ordinary 60 Hz NMI then calls `play` once per frame. Audio
367    /// is produced through the unchanged lockstep loop; there is no video.
368    ///
369    /// Uses the default 44.1 kHz sample rate; see
370    /// [`Nes::from_nsf_with_sample_rate`].
371    ///
372    /// # Errors
373    ///
374    /// Returns the underlying [`RomError`] when the NSF header is malformed.
375    pub fn from_nsf(nsf_bytes: &[u8]) -> Result<Self, RomError> {
376        Self::from_nsf_with_sample_rate(nsf_bytes, crate::bus::DEFAULT_SAMPLE_RATE)
377    }
378
379    /// Build an NSF player with an explicit audio sample rate. See
380    /// [`Nes::from_nsf`].
381    ///
382    /// # Errors
383    ///
384    /// Returns the underlying [`RomError`] when the NSF header is malformed.
385    pub fn from_nsf_with_sample_rate(nsf_bytes: &[u8], sample_rate: u32) -> Result<Self, RomError> {
386        let mut bus = LockstepBus::with_nsf(nsf_bytes, sample_rate)?;
387        let mut cpu = Cpu::power_on();
388        cpu.reset(&mut bus);
389        Ok(Self {
390            cpu,
391            bus,
392            rom_sha256: sha256_of(nsf_bytes),
393            rewind: None,
394            rewind_capture_enabled: true,
395            rewind_snap_buf: Vec::new(),
396            #[cfg(feature = "cpu-boot-trace")]
397            cpu_boot_trace: None,
398            #[cfg(feature = "debug-hooks")]
399            breakpoints: Vec::new(),
400            #[cfg(feature = "debug-hooks")]
401            breakpoints_enabled: true,
402            #[cfg(feature = "debug-hooks")]
403            break_hit: None,
404            #[cfg(feature = "debug-hooks")]
405            skip_breakpoint_at: None,
406            #[cfg(feature = "debug-hooks")]
407            trace: alloc::collections::VecDeque::new(),
408            #[cfg(feature = "debug-hooks")]
409            trace_enabled: false,
410            #[cfg(feature = "debug-hooks")]
411            exec_log: Vec::new(),
412            #[cfg(feature = "debug-hooks")]
413            exec_logging: false,
414        })
415    }
416
417    /// Number of selectable songs in the loaded NSF (0 for a cartridge / disk).
418    #[must_use]
419    pub fn nsf_song_count(&self) -> u8 {
420        self.bus.nsf_song_count()
421    }
422
423    /// The currently-selected 0-based NSF song (0 for a cartridge / disk).
424    #[must_use]
425    pub fn nsf_current_song(&self) -> u8 {
426        self.bus.nsf_current_song()
427    }
428
429    /// Select a 0-based NSF song and restart playback on it (re-runs `init` via
430    /// a warm reset). No-op for a cartridge / disk.
431    pub fn nsf_set_song(&mut self, song: u8) {
432        if self.bus.nsf_set_song(song) {
433            // Re-vector through the driver's reset entry so `init` runs for the
434            // new track. Warm reset preserves the freshly-patched driver state.
435            self.reset();
436        }
437    }
438
439    /// Build an emulator with a **randomized power-on RAM** state (developer
440    /// mode; Phase 7 / T-72-005).
441    ///
442    /// Identical to [`Nes::from_rom`] except the 2 KiB CPU work RAM and the
443    /// open-bus latch are filled from a deterministic `xorshift64` PRNG keyed
444    /// on `seed`, modelling the unreliable power-on RAM of real hardware
445    /// (nesdev "CPU power up state"). Use this to shake out game/test code
446    /// that depends on a particular post-power-on RAM pattern.
447    ///
448    /// The randomization is **seeded and deterministic** — the same `seed`
449    /// yields the same state, so the `same seed + ROM + input ⇒ bit-identical`
450    /// contract still holds. The default [`Nes::from_rom`] (zeroed RAM) is
451    /// what CI, the regression oracle, and save-state tests use.
452    ///
453    /// # Errors
454    ///
455    /// Returns the underlying [`RomError`] if the bytes don't parse.
456    pub fn from_rom_with_power_on_seed(bytes: &[u8], seed: u64) -> Result<Self, RomError> {
457        Self::from_rom_with_power_on_config(
458            bytes,
459            PowerOnConfig {
460                ram: PowerOnRam::Seeded(seed),
461            },
462        )
463    }
464
465    /// v2.1.7 P5 — build an emulator with an explicit [`PowerOnConfig`].
466    ///
467    /// Generalizes [`Nes::from_rom_with_power_on_seed`]: the caller chooses the
468    /// power-on work-RAM fill ([`PowerOnRam::Zeroed`] / [`PowerOnRam::Seeded`] /
469    /// [`PowerOnRam::Filled`]). The config is stored on the bus so a subsequent
470    /// power-cycle re-applies the same fill (keeping `power_cycle == fresh
471    /// boot`). All fills are **deterministic**, so the `same config + ROM + input
472    /// ⇒ bit-identical` contract still holds. [`PowerOnConfig::default`]
473    /// ([`PowerOnRam::Zeroed`]) is byte-identical to [`Nes::from_rom`].
474    ///
475    /// # Errors
476    ///
477    /// Returns the underlying [`RomError`] if the bytes don't parse.
478    pub fn from_rom_with_power_on_config(
479        bytes: &[u8],
480        config: PowerOnConfig,
481    ) -> Result<Self, RomError> {
482        let mut nes = Self::from_rom(bytes)?;
483        // RAM is not consulted during the reset sequence (only the $FFFC/D
484        // vector is), so applying the fill after construction is correct.
485        nes.bus.set_power_on_ram(config.ram);
486        Ok(nes)
487    }
488
489    /// Reset (warm boot). Preserves WRAM; reloads PC from `$FFFC/D`.
490    pub fn reset(&mut self) {
491        self.bus.reset();
492        self.cpu.reset(&mut self.bus);
493    }
494
495    /// Power-cycle (cold boot). Zeroes WRAM, re-rolls phase, reloads vectors.
496    pub fn power_cycle(&mut self) {
497        self.bus.power_cycle();
498        // Cold-boot path: see comment in `from_rom`.
499        self.cpu = Cpu::power_on();
500        self.cpu.reset(&mut self.bus);
501    }
502
503    /// Run until the PPU finishes a frame. Returns the framebuffer slice.
504    ///
505    /// # Panics
506    ///
507    /// Panics if the CPU JAMs without producing a frame. Real software
508    /// shouldn't JAM; if it does, the caller's run-loop should catch it
509    /// before the next frame.
510    pub fn run_frame(&mut self) -> &[u8] {
511        // Hard cap: at NTSC the frame budget is 29,780.5 CPU cycles. Run
512        // up to 5x that before bailing — gives breathing room for late
513        // VBL detection or DMA-stall heavy frames before declaring "stuck".
514        const MAX_CYCLES_PER_FRAME: u64 = 150_000;
515        let start = self.bus.cycle();
516        // T-110-C3 — the event viewer shows one frame; reset the log per frame.
517        #[cfg(feature = "debug-hooks")]
518        if self.bus.event_logging() {
519            self.bus.clear_events();
520        }
521        // T-110-E2 — the Lua onRead/onWrite access log is per-frame too.
522        #[cfg(feature = "debug-hooks")]
523        if self.bus.access_logging() {
524            self.bus.clear_accesses();
525        }
526        // T-110-E1 — the Lua onNmi/onIrq interrupt-service log is per-frame too
527        // (cleared here so a replay only ever sees this frame's services, never
528        // a stale carry-over — mirrors the exec_log clear below).
529        #[cfg(feature = "debug-hooks")]
530        if self.bus.interrupt_logging() {
531            self.bus.clear_interrupts();
532        }
533        // v1.6.0 Workstream A3 — reset the `TAStudio` lag-log "controller polled"
534        // flag so was_input_polled_this_frame() reflects only this frame (a
535        // frame that ends still-`false` is a lag frame). Output-only.
536        #[cfg(feature = "debug-hooks")]
537        self.bus.clear_controller_polled();
538        // v1.4.0 Workstream D (D2) — start each frame with no event-breakpoint
539        // hit so the frontend's "first hit of the frame" pause is per-frame.
540        #[cfg(feature = "debug-hooks")]
541        if self.bus.event_breakpoints() != 0 {
542            self.bus.clear_event_break_hit();
543        }
544        // T-110-E2 — the Lua onExec exec-PC log is per-frame (cleared here so a
545        // replay only ever sees this frame's PCs, never a stale carry-over).
546        #[cfg(feature = "debug-hooks")]
547        if self.exec_logging {
548            self.exec_log.clear();
549        }
550        while !self.bus.take_frame_complete() {
551            if self.cpu.is_jammed() {
552                break;
553            }
554            if self.bus.cycle().wrapping_sub(start) > MAX_CYCLES_PER_FRAME {
555                break;
556            }
557            #[cfg(feature = "debug-hooks")]
558            {
559                // v1.1.0 beta.2 (Workstream C) — exec/PC breakpoints. The
560                // `skip_breakpoint_at` PC is stepped past exactly once (so a
561                // "continue" resumes off the instruction it stopped on instead
562                // of re-breaking in place); any OTHER breakpoint PC — including
563                // one at the frame's starting PC after a reset / save-state load
564                // / manual PC change — still fires immediately.
565                if self.breakpoints_enabled && self.breakpoints.contains(&self.cpu.pc) {
566                    if self.skip_breakpoint_at == Some(self.cpu.pc) {
567                        self.skip_breakpoint_at = None;
568                    } else {
569                        // Hit: stop the (partial) frame and report the PC. No
570                        // state mutated; the frame simply isn't completed.
571                        self.break_hit = Some(self.cpu.pc);
572                        self.skip_breakpoint_at = Some(self.cpu.pc);
573                        return self.bus.framebuffer();
574                    }
575                } else {
576                    self.skip_breakpoint_at = None;
577                }
578                // T-110-E2 — per-frame exec-PC log for Lua onExec (bounded by
579                // MAX_CYCLES_PER_FRAME, so no explicit cap needed).
580                if self.exec_logging {
581                    self.exec_log.push(self.cpu.pc);
582                }
583                // T-110-C2 — cycle trace: record the about-to-execute
584                // instruction's CPU state (ring-capped, oldest dropped).
585                if self.trace_enabled {
586                    if self.trace.len() >= Self::TRACE_CAP {
587                        self.trace.pop_front();
588                    }
589                    self.trace.push_back(TraceRec {
590                        pc: self.cpu.pc,
591                        a: self.cpu.a,
592                        x: self.cpu.x,
593                        y: self.cpu.y,
594                        s: self.cpu.s,
595                        p: self.cpu.p.bits(),
596                        cycle: self.cpu.cycles,
597                    });
598                }
599            }
600            #[cfg(feature = "cpu-boot-trace")]
601            self.cpu_boot_trace_record();
602            self.cpu.step(&mut self.bus);
603        }
604        // Sample any attached Zapper's light detection from the completed
605        // frame. This is a no-op (and the run loop above is byte-identical)
606        // when no Zapper is attached, so the determinism contract holds.
607        self.bus.sample_zapper_light();
608        // After the frame completes, push state into the rewind ring so
609        // the frontend's hold-F5 UX has somewhere to walk back from.
610        // v2.8.0 Phase 3 — run-ahead suppresses the capture for its hidden
611        // + visible frames via `set_rewind_capture(false)`.
612        if self.rewind.is_some() && self.rewind_capture_enabled {
613            self.rewind_capture();
614        }
615        self.bus.framebuffer()
616    }
617
618    /// v2.8.0 Phase 3 — enable/disable the per-frame rewind capture while
619    /// the ring stays armed. Run-ahead turns it off around its hidden +
620    /// visible frames so only persistent-timeline frames land in the ring.
621    /// Default `true`; with no rewind ring armed this is a no-op.
622    pub const fn set_rewind_capture(&mut self, enabled: bool) {
623        self.rewind_capture_enabled = enabled;
624    }
625
626    /// Step exactly one CPU instruction. For debuggers / step-through tools.
627    pub fn step_instruction(&mut self) -> u8 {
628        #[cfg(feature = "cpu-boot-trace")]
629        self.cpu_boot_trace_record();
630        self.cpu.step(&mut self.bus)
631    }
632
633    /// v1.1.0 beta.2 (Workstream C) — add an exec/PC breakpoint at `addr`.
634    /// [`Nes::run_frame`] stops the frame the next time the program counter
635    /// reaches `addr` (reportable via [`Nes::take_break_hit`]). Idempotent.
636    /// `debug-hooks` only.
637    #[cfg(feature = "debug-hooks")]
638    pub fn add_breakpoint(&mut self, addr: u16) {
639        if !self.breakpoints.contains(&addr) {
640            self.breakpoints.push(addr);
641        }
642    }
643
644    /// Remove a previously-added exec breakpoint (no-op if absent).
645    #[cfg(feature = "debug-hooks")]
646    pub fn remove_breakpoint(&mut self, addr: u16) {
647        self.breakpoints.retain(|&a| a != addr);
648    }
649
650    /// Remove all breakpoints.
651    #[cfg(feature = "debug-hooks")]
652    pub fn clear_breakpoints(&mut self) {
653        self.breakpoints.clear();
654    }
655
656    /// The current exec breakpoints (insertion order).
657    #[cfg(feature = "debug-hooks")]
658    #[must_use]
659    // `Vec` -> slice deref coercion is not const, so this can't be `const fn`
660    // (clippy's `missing_const_for_fn` is a false positive here).
661    #[allow(clippy::missing_const_for_fn)]
662    pub fn breakpoints(&self) -> &[u16] {
663        &self.breakpoints
664    }
665
666    /// Arm/disarm breakpoint checking without discarding the list. Default on.
667    #[cfg(feature = "debug-hooks")]
668    pub const fn set_breakpoints_enabled(&mut self, enabled: bool) {
669        self.breakpoints_enabled = enabled;
670    }
671
672    /// Whether breakpoint checking is armed.
673    #[cfg(feature = "debug-hooks")]
674    #[must_use]
675    pub const fn breakpoints_enabled(&self) -> bool {
676        self.breakpoints_enabled
677    }
678
679    /// Take the PC that last hit a breakpoint (cleared on read). The frontend
680    /// polls this after [`Nes::run_frame`] to pause when a breakpoint fired.
681    #[cfg(feature = "debug-hooks")]
682    pub const fn take_break_hit(&mut self) -> Option<u16> {
683        self.break_hit.take()
684    }
685
686    /// v1.4.0 Workstream D (D2) — arm the event-driven breakpoint categories
687    /// (a bit-OR of [`crate::EventBpKind::bit`]). `0` (default) disarms every
688    /// category — the per-access taps are then a single cheap `mask == 0`
689    /// early-out. Output-only: a hit pauses + reports but never mutates state.
690    /// `debug-hooks` only.
691    #[cfg(feature = "debug-hooks")]
692    pub const fn set_event_breakpoints(&mut self, mask: u16) {
693        self.bus.set_event_breakpoints(mask);
694    }
695
696    /// The armed event-breakpoint category mask.
697    #[cfg(feature = "debug-hooks")]
698    #[must_use]
699    pub const fn event_breakpoints(&self) -> u16 {
700        self.bus.event_breakpoints()
701    }
702
703    /// Take the first event-breakpoint hit of the current frame (cleared on
704    /// read). The frontend polls this after [`Nes::run_frame`] to pause when an
705    /// armed hardware event fired, reporting its kind + frame/cycle/scanline/dot.
706    #[cfg(feature = "debug-hooks")]
707    pub const fn take_event_break_hit(&mut self) -> Option<crate::EventBreakHit> {
708        self.bus.take_event_break_hit()
709    }
710
711    /// Maximum cycle-trace ring depth (oldest records drop past this).
712    #[cfg(feature = "debug-hooks")]
713    pub const TRACE_CAP: usize = 50_000;
714
715    /// v1.1.0 beta.2 (T-110-C2) — start/stop the cycle-trace logger. While on,
716    /// each executed instruction's CPU state is pushed to a ring buffer (capped
717    /// at [`Self::TRACE_CAP`]). Default off.
718    #[cfg(feature = "debug-hooks")]
719    pub const fn set_trace_enabled(&mut self, enabled: bool) {
720        self.trace_enabled = enabled;
721    }
722
723    /// Whether the cycle-trace logger is recording.
724    #[cfg(feature = "debug-hooks")]
725    #[must_use]
726    pub const fn trace_enabled(&self) -> bool {
727        self.trace_enabled
728    }
729
730    /// Number of records currently in the trace ring.
731    #[cfg(feature = "debug-hooks")]
732    #[must_use]
733    pub fn trace_len(&self) -> usize {
734        self.trace.len()
735    }
736
737    /// Clear the trace ring.
738    #[cfg(feature = "debug-hooks")]
739    pub fn clear_trace(&mut self) {
740        self.trace.clear();
741    }
742
743    /// Copy the trace ring oldest-first (for the trace panel / file export).
744    #[cfg(feature = "debug-hooks")]
745    #[must_use]
746    pub fn trace_records(&self) -> alloc::vec::Vec<TraceRec> {
747        self.trace.iter().copied().collect()
748    }
749
750    /// Copy the most recent `n` trace records (oldest-first) — for the live
751    /// trace panel's tail view, cheaper than [`Self::trace_records`] on a full
752    /// ring.
753    #[cfg(feature = "debug-hooks")]
754    #[must_use]
755    pub fn trace_tail_vec(&self, n: usize) -> alloc::vec::Vec<TraceRec> {
756        let skip = self.trace.len().saturating_sub(n);
757        self.trace.iter().skip(skip).copied().collect()
758    }
759
760    /// v1.1.0 beta.2 (T-110-C3) — start/stop the event viewer. While on, the bus
761    /// records this frame's PPU/APU/mapper writes (with their PPU position); the
762    /// log is reset at each [`Self::run_frame`]. Default off; output-only.
763    #[cfg(feature = "debug-hooks")]
764    pub const fn set_event_logging(&mut self, enabled: bool) {
765        self.bus.set_event_logging(enabled);
766    }
767
768    /// Whether the event viewer is recording.
769    #[cfg(feature = "debug-hooks")]
770    #[must_use]
771    pub const fn event_logging(&self) -> bool {
772        self.bus.event_logging()
773    }
774
775    /// The current frame's captured events (for the event-viewer panel).
776    #[cfg(feature = "debug-hooks")]
777    #[must_use]
778    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
779    pub fn events(&self) -> &[crate::bus::EventRec] {
780        self.bus.events()
781    }
782
783    /// v1.1.0 beta.3 (T-110-E2) — start/stop the Lua bus-access log. While on,
784    /// the bus records this frame's CPU reads + writes (with values); the log is
785    /// reset at each [`Self::run_frame`]. Default off; output-only. Enabled by
786    /// the scripting engine only while `onRead`/`onWrite` callbacks exist.
787    #[cfg(feature = "debug-hooks")]
788    pub const fn set_access_logging(&mut self, enabled: bool) {
789        self.bus.set_access_logging(enabled);
790    }
791
792    /// Whether the bus-access log is recording.
793    #[cfg(feature = "debug-hooks")]
794    #[must_use]
795    pub const fn access_logging(&self) -> bool {
796        self.bus.access_logging()
797    }
798
799    /// The current frame's captured CPU bus accesses (for the Lua engine).
800    #[cfg(feature = "debug-hooks")]
801    #[must_use]
802    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
803    pub fn accesses(&self) -> &[crate::bus::AccessRec] {
804        self.bus.accesses()
805    }
806
807    /// v1.1.0 beta.3 (T-110-E2) — start/stop the per-frame exec-PC log for the
808    /// Lua `onExec` callback. Independent of the Trace Logger (`set_trace_enabled`),
809    /// so enabling it does not disturb the debugger's trace recording. Cleared
810    /// every [`Self::run_frame`]; output-only.
811    #[cfg(feature = "debug-hooks")]
812    pub const fn set_exec_logging(&mut self, enabled: bool) {
813        self.exec_logging = enabled;
814    }
815
816    /// Whether the per-frame exec-PC log is recording.
817    #[cfg(feature = "debug-hooks")]
818    #[must_use]
819    pub const fn exec_logging(&self) -> bool {
820        self.exec_logging
821    }
822
823    /// This frame's executed PCs, in execution order (for the Lua engine).
824    #[cfg(feature = "debug-hooks")]
825    #[must_use]
826    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
827    pub fn exec_log(&self) -> &[u16] {
828        &self.exec_log
829    }
830
831    /// `true` if the running program read a controller port (`$4016`/`$4017`)
832    /// during the most recent [`Self::run_frame`] — the inverse of a `TAStudio`
833    /// "lag frame" (v1.6.0 Workstream A3). The greenzone / piano-roll lag log
834    /// queries this each frame. Output-only; `debug-hooks`-gated, so the
835    /// shipped build is byte-identical and the determinism contract holds.
836    #[cfg(feature = "debug-hooks")]
837    #[must_use]
838    pub const fn was_input_polled_this_frame(&self) -> bool {
839        self.bus.controller_polled()
840    }
841
842    /// v1.2.0 (T-110-E1) — start/stop the per-frame interrupt-service log for
843    /// the Lua `onNmi` / `onIrq` callbacks. The log records this frame's
844    /// committed NMI / IRQ / BRK service entries (captured at the CPU's
845    /// service-vector commit point, NOT the speculative poll sampler); it is
846    /// cleared at each [`Self::run_frame`]. Default off; output-only. Enabled by
847    /// the scripting engine only while `onNmi`/`onIrq` callbacks exist. Mirrors
848    /// [`Self::set_exec_logging`].
849    #[cfg(feature = "debug-hooks")]
850    pub const fn set_interrupt_logging(&mut self, enabled: bool) {
851        self.bus.set_interrupt_logging(enabled);
852    }
853
854    /// Whether the per-frame interrupt-service log is recording.
855    #[cfg(feature = "debug-hooks")]
856    #[must_use]
857    pub const fn interrupt_logging(&self) -> bool {
858        self.bus.interrupt_logging()
859    }
860
861    /// This frame's committed interrupt-service entries, in service order (for
862    /// the Lua engine). Mirrors [`Self::exec_log`] / [`Self::accesses`].
863    #[cfg(feature = "debug-hooks")]
864    #[must_use]
865    #[allow(clippy::missing_const_for_fn)] // slice deref is not const.
866    pub fn interrupt_log(&self) -> &[crate::bus::InterruptRec] {
867        self.bus.interrupts()
868    }
869
870    /// Borrow the framebuffer (RGBA8, 256x240).
871    #[must_use]
872    pub fn framebuffer(&self) -> &[u8] {
873        self.bus.framebuffer()
874    }
875
876    /// v1.7.0 "Forge" Workstream B (B3) — overwrite the RGBA8 output framebuffer
877    /// (the Lua `emu:setScreenBuffer(t)` paints output only). Output-only; see
878    /// [`rustynes_ppu::Ppu::debug_set_framebuffer`]. Reached only through the
879    /// script crate's gated post-frame path, so the shipped build is
880    /// byte-identical and the determinism contract holds. `debug-hooks`-gated.
881    #[cfg(feature = "debug-hooks")]
882    pub fn debug_set_framebuffer(&mut self, rgba: &[u8]) {
883        self.bus.debug_set_framebuffer(rgba);
884    }
885
886    /// Borrow the parallel palette-index framebuffer (256x240 `u16`s, each
887    /// `(emphasis << 6) | colour`) for the `NES_NTSC` composite filter.
888    /// See [`rustynes_ppu::Ppu::index_framebuffer`].
889    #[must_use]
890    pub fn index_framebuffer(&self) -> &[u16] {
891        self.bus.index_framebuffer()
892    }
893
894    /// v1.2.0 C3 (hd-pack) — borrow the per-pixel HD-pack tile-source buffer
895    /// (256x240 [`rustynes_ppu::HdTileSource`] records). Each entry names the
896    /// CHR tile that produced the pixel; the frontend HD-pack loader groups
897    /// these by 8x8 cell, hashes the CHR bytes, and substitutes hi-res tiles.
898    /// Output-only telemetry; the determinism contract is unaffected. See
899    /// [`rustynes_ppu::Ppu::hd_tile_source`].
900    #[cfg(feature = "hd-pack")]
901    #[must_use]
902    pub fn hd_tile_source(&self) -> &[rustynes_ppu::HdTileSource] {
903        self.bus.hd_tile_source()
904    }
905
906    /// The frame's background scroll `(x, y)` in NES pixels, for offsetting
907    /// parallax HD-pack `<background>` layers (see
908    /// [`rustynes_ppu::Ppu::hd_bg_scroll`]). Output-only.
909    #[cfg(feature = "hd-pack")]
910    #[must_use]
911    pub const fn hd_bg_scroll(&self) -> (i32, i32) {
912        self.bus.ppu().hd_bg_scroll()
913    }
914
915    /// The per-frame NTSC composite colour phase consumed by the `NES_NTSC`
916    /// filter (`0..=2` on NTSC; frame parity `0..=1` on PAL/Dendy). See
917    /// [`rustynes_ppu::Ppu::ntsc_phase`].
918    #[must_use]
919    pub const fn ntsc_phase(&self) -> u8 {
920        self.bus.ntsc_phase()
921    }
922
923    /// The completed-frame counter (PPU frames since power-on). A monotonic,
924    /// deterministic, save-state-restored value — the frontend uses it to phase
925    /// turbo/autofire so the strobe is reproducible under rollback / TAS replay.
926    #[must_use]
927    pub const fn frame(&self) -> u64 {
928        self.bus.ppu().frame()
929    }
930
931    /// Borrow the underlying bus (debugger / tests).
932    #[must_use]
933    pub const fn bus(&self) -> &LockstepBus {
934        &self.bus
935    }
936
937    /// Mutably borrow the underlying bus (debugger / tests).
938    pub const fn bus_mut(&mut self) -> &mut LockstepBus {
939        &mut self.bus
940    }
941
942    /// Borrow the CPU (debugger / tests).
943    #[must_use]
944    pub const fn cpu(&self) -> &Cpu {
945        &self.cpu
946    }
947
948    /// Cumulative CPU cycle count.
949    #[must_use]
950    pub const fn cycle(&self) -> u64 {
951        self.bus.cycle()
952    }
953
954    /// `true` when the CPU has executed a JAM/KIL/STP and is halted.
955    ///
956    /// (v2.0.0 beta.5: the `VsDualSystem` soft-lockstep driver guards its
957    /// per-instruction stepping — the pre-existing debugger
958    /// [`Self::step_instruction`] — on this.)
959    #[must_use]
960    pub const fn is_jammed(&self) -> bool {
961        self.cpu.is_jammed()
962    }
963
964    /// Cartridge region (NTSC / PAL / Dendy / Multi). Drives wall-clock
965    /// frame pacing in the frontend and clock dividers in the chip cores.
966    #[must_use]
967    pub const fn region(&self) -> Region {
968        match self.bus.region() {
969            rustynes_mappers::Region::Pal => Region::Pal,
970            rustynes_mappers::Region::Dendy => Region::Dendy,
971            // iNES 1.0 "Multi" cartridges are treated as NTSC for pacing
972            // (matches the PPU / APU init in `LockstepBus::with_sample_rate`).
973            _ => Region::Ntsc,
974        }
975    }
976
977    /// Length in bytes of the loaded cartridge's PRG-ROM (read-only metadata).
978    ///
979    /// Exposed for the Lua scripting `cart:prg_size()` query (and any other
980    /// read-only consumer); does not touch deterministic state.
981    #[must_use]
982    pub const fn prg_rom_len(&self) -> usize {
983        self.bus.prg_rom_len()
984    }
985
986    /// Length in bytes of the loaded cartridge's CHR-ROM (0 when the board uses
987    /// CHR-RAM). Read-only metadata; backs the Lua `cart:chr_size()` query.
988    #[must_use]
989    pub const fn chr_rom_len(&self) -> usize {
990        self.bus.chr_rom_len()
991    }
992
993    /// The loaded mapper's iNES / NES 2.0 mapper id (backs `cart:mapper_id()`).
994    #[must_use]
995    pub fn mapper_id(&self) -> u16 {
996        self.bus.mapper_debug_info().mapper_id
997    }
998
999    /// Wall-clock frame duration for this cartridge's region. The frontend
1000    /// uses this to pace emulator advance independently of monitor refresh
1001    /// rate — without it, `Fifo` present mode on a 144 Hz monitor would
1002    /// run the emulator 2.4× too fast.
1003    #[must_use]
1004    pub const fn frame_duration(&self) -> Duration {
1005        match self.region() {
1006            Region::Pal => FRAME_DURATION_PAL,
1007            Region::Dendy => FRAME_DURATION_DENDY,
1008            Region::Ntsc => FRAME_DURATION_NTSC,
1009        }
1010    }
1011
1012    /// Drain accumulated audio samples (host sample rate, normalized
1013    /// `[0.0, ~1.0]`).  Call once per frame from the frontend's audio thread
1014    /// or batch driver.
1015    pub fn drain_audio(&mut self) -> Vec<f32> {
1016        self.bus.drain_audio()
1017    }
1018
1019    /// Set the buttons currently held on player `port`. Ports 0/1 are the
1020    /// standard controllers (`$4016`/`$4017`); ports 2/3 are players 3/4 on
1021    /// the Four Score adapter (only polled when [`Self::set_four_score`] is
1022    /// on). The change takes effect on the next strobe edge.
1023    ///
1024    /// # Panics
1025    ///
1026    /// Panics if `port` is not in `0..=3`.
1027    pub const fn set_buttons(&mut self, port: usize, buttons: Buttons) {
1028        self.bus.set_buttons(port, buttons);
1029    }
1030
1031    /// Get the buttons currently held on player `port` (0/1 = `$4016`/`$4017`;
1032    /// 2/3 = Four Score players 3/4). Read-only; does not advance emulator
1033    /// state.
1034    ///
1035    /// Used by the TAS movie recorder (`crate::movie`) to capture the inputs
1036    /// applied before each [`Self::run_frame`]. (Movies record players 1 & 2;
1037    /// Four Score players 3/4 are not part of the `.rnm` stream.)
1038    ///
1039    /// # Panics
1040    ///
1041    /// Panics if `port` is not in `0..=3`.
1042    #[must_use]
1043    pub const fn buttons(&self, port: usize) -> Buttons {
1044        self.bus.controller(port).buttons()
1045    }
1046
1047    /// Enable/disable the Four Score 4-player adapter. Off by default; while
1048    /// off, controller reads are byte-identical to the standard two-pad
1049    /// behavior (the determinism contract and save-states are unaffected).
1050    /// When on, players 3/4 (ports 2/3) are multiplexed onto `$4016`/`$4017`
1051    /// across a 24-read serial sequence.
1052    pub const fn set_four_score(&mut self, enabled: bool) {
1053        self.bus.set_four_score(enabled);
1054    }
1055
1056    /// Whether the Four Score adapter is currently enabled.
1057    #[must_use]
1058    pub const fn four_score(&self) -> bool {
1059        self.bus.four_score()
1060    }
1061
1062    // --- Vs. System DIP switches + coin/service inputs ---
1063
1064    /// True when the running cart is Nintendo Vs. System arcade hardware
1065    /// (NES 2.0 console type = Vs. System). The RGB PPU + DIP/coin inputs only
1066    /// take effect on such carts.
1067    #[must_use]
1068    pub fn is_vs_system(&self) -> bool {
1069        self.bus.is_vs_system()
1070    }
1071
1072    /// True when the cart's header marks a Vs. `DualSystem` board (two CPUs /
1073    /// two PPUs; NES 2.0 byte-13 high nibble = Vs. hardware type 5/6).
1074    ///
1075    /// Detection only: this single-system core cannot boot a `DualSystem` title
1076    /// past its attract handshake, so the frontend uses this to surface a clear
1077    /// note. The two-CPU/two-PPU emulation is a documented v2.0 deferral
1078    /// (`docs/audit/vs-dualsystem-design-2026-06-11.md`).
1079    #[must_use]
1080    pub const fn is_vs_dual_system(&self) -> bool {
1081        self.bus.is_vs_dual_system()
1082    }
1083
1084    /// Set the Vs. System 8-bit DIP-switch bank (switch 1 = bit 0 .. switch 8 =
1085    /// bit 7). Read through the upper bits of `$4016`/`$4017`. No effect on
1086    /// non-Vs. carts; the standard controller read stays byte-identical.
1087    pub const fn set_vs_dip(&mut self, dip: u8) {
1088        self.bus.set_vs_dip(dip);
1089    }
1090
1091    /// Current Vs. System DIP-switch bank.
1092    #[must_use]
1093    pub const fn vs_dip(&self) -> u8 {
1094        self.bus.vs_dip()
1095    }
1096
1097    /// Override the Vs. System PPU type and re-apply the output palette.
1098    ///
1099    /// iNES-1.0 Vs. dumps default to the 2C03 palette (no NES 2.0 byte-13);
1100    /// the per-game database ([`crate::vs_db`]) supplies the correct
1101    /// 2C04-000x / 2C05 type, which the frontend applies through this setter.
1102    /// Affects only the colour LUT the PPU emits through, never game logic.
1103    /// No effect on non-Vs. carts.
1104    pub const fn set_vs_ppu_type(&mut self, t: rustynes_mappers::VsPpuType) {
1105        self.bus.set_vs_ppu_type(t);
1106    }
1107
1108    /// Latch a Vs. System coin insertion on the given acceptor (0 = #1, 1 = #2).
1109    /// Reads true for a real-hardware ~40-70 ms window; the frontend should
1110    /// clear it (see [`Self::clear_coin`]) after a few frames.
1111    pub const fn insert_coin(&mut self, acceptor: u8) {
1112        self.bus.insert_coin(acceptor);
1113    }
1114
1115    /// Clear all latched Vs. System coin-insert signals.
1116    pub const fn clear_coin(&mut self) {
1117        self.bus.clear_coin();
1118    }
1119
1120    /// Set / clear the Vs. System service button.
1121    pub const fn set_vs_service(&mut self, pressed: bool) {
1122        self.bus.set_vs_service(pressed);
1123    }
1124
1125    // --- Famicom Disk System disk control (Stage 2b) ---
1126
1127    /// Number of disk sides in the inserted FDS image. Returns 0 for cartridge
1128    /// builds (so a frontend can branch on "is this an FDS game?").
1129    #[must_use]
1130    pub fn disk_side_count(&self) -> usize {
1131        self.bus.disk_side_count()
1132    }
1133
1134    /// The currently inserted FDS disk side index, or `None` when ejected (or
1135    /// for a cartridge build). A game that prompts "insert side B" is asking the
1136    /// user to call [`Self::set_disk_side`].
1137    #[must_use]
1138    pub fn inserted_disk_side(&self) -> Option<usize> {
1139        self.bus.inserted_disk_side()
1140    }
1141
1142    /// Insert FDS side `i` (`Some(i)`) or eject the disk (`None`). Inserting
1143    /// resets the head and opens a short deterministic "not ready" window (the
1144    /// BIOS polls `$4032` and waits for ready); an out-of-range index is
1145    /// ignored. No-op on cartridge builds. This is how the user complies with a
1146    /// game's "insert side N" prompt.
1147    pub fn set_disk_side(&mut self, side: Option<usize>) {
1148        self.bus.set_disk_side(side);
1149    }
1150
1151    /// Start recording the diagnostic FDS read-stream trace (the `$4031` disk-byte
1152    /// stream + `$4025` control writes + side changes). Off by default and
1153    /// observation-only — it never affects emulation, so the determinism contract
1154    /// holds. Drain it with [`Self::take_fds_trace`]. No-op on cartridge builds.
1155    /// Used by the `fds_trace` diagnostic harness to debug disk-read / side-swap
1156    /// failures (e.g. the Kid Icarus side-B `ERR.07` stall).
1157    pub fn enable_fds_trace(&mut self) {
1158        self.bus.enable_fds_trace();
1159    }
1160
1161    /// Drain the accumulated FDS read-stream trace records. Empty for cartridge
1162    /// builds or when [`Self::enable_fds_trace`] was never called.
1163    #[must_use]
1164    pub fn take_fds_trace(&mut self) -> Vec<rustynes_mappers::FdsTraceRec> {
1165        self.bus.take_fds_trace()
1166    }
1167
1168    /// Re-serialize the (possibly-modified) FDS disk image to the headerless
1169    /// `.fds` byte layout so the host can write it to a side-car `.fds.sav`
1170    /// (keyed by [`Self::rom_sha256`]). Empty for cartridge builds.
1171    #[must_use]
1172    pub fn disk_image_bytes(&self) -> Vec<u8> {
1173        self.bus.disk_image_bytes()
1174    }
1175
1176    /// Whether the FDS disk image has unsaved writes since the last
1177    /// [`Self::clear_disk_dirty`]. A frontend checks this on quit / periodically
1178    /// to decide whether to persist the disk.
1179    #[must_use]
1180    pub fn disk_is_dirty(&self) -> bool {
1181        self.bus.disk_is_dirty()
1182    }
1183
1184    /// Clear the FDS disk dirty flag after persisting the image.
1185    pub fn clear_disk_dirty(&mut self) {
1186        self.bus.clear_disk_dirty();
1187    }
1188
1189    /// Mark the inserted FDS disk read-only (`true`) or writable (`false`,
1190    /// the default). Drives the `$4032` write-protect flag; a write-protected
1191    /// disk drops bytes in write mode without modifying the medium.
1192    pub fn set_disk_write_protected(&mut self, protected: bool) {
1193        self.bus.set_disk_write_protected(protected);
1194    }
1195
1196    /// Attach a non-standard overlay input device on `port` (0 = `$4016`, 1 =
1197    /// `$4017`). Pass `None` to unplug it and return the port to the standard
1198    /// controller / Four Score path (byte-identical reads). Devices are
1199    /// unplugged on power-cycle.
1200    ///
1201    /// # Panics
1202    ///
1203    /// Panics if `port` is not in `0..=1`.
1204    pub fn set_expansion_device(&mut self, port: usize, device: Option<InputDevice>) {
1205        self.bus.set_expansion_device(port, device);
1206    }
1207
1208    /// Borrow the overlay device attached to `port` (0 = `$4016`, 1 =
1209    /// `$4017`), if any.
1210    ///
1211    /// # Panics
1212    ///
1213    /// Panics if `port` is not in `0..=1`.
1214    #[must_use]
1215    pub const fn expansion_device(&self, port: usize) -> &Option<InputDevice> {
1216        self.bus.expansion_device(port)
1217    }
1218
1219    /// Attach an Arkanoid "Vaus" paddle on `port` (typically port 1 / `$4017`)
1220    /// and set its position + fire state. `position` is the raw 8-bit
1221    /// potentiometer value (`$00` far-left .. `$FF` far-right); `fire` is the
1222    /// single button. Convenience wrapper that attaches the device if absent
1223    /// then updates it.
1224    ///
1225    /// # Panics
1226    ///
1227    /// Panics if `port` is not in `0..=1`.
1228    pub fn set_paddle(&mut self, port: usize, position: u8, fire: bool) {
1229        if !matches!(self.bus.expansion_device(port), Some(InputDevice::Vaus(_))) {
1230            self.bus.set_expansion_device(
1231                port,
1232                Some(InputDevice::Vaus(crate::input_device::VausState::new())),
1233            );
1234        }
1235        self.bus.set_paddle(port, position, fire);
1236    }
1237
1238    /// Attach an NES Zapper light gun on `port` (typically port 1 / `$4017`)
1239    /// and set its aim point + trigger. `(x, y)` is the screen pixel the gun is
1240    /// aimed at (0..256, 0..240; out of range = off-screen); `trigger` is the
1241    /// trigger state. Convenience wrapper that attaches the device if absent
1242    /// then updates it.
1243    ///
1244    /// Light detection is sampled from the framebuffer at the end of each
1245    /// [`Self::run_frame`]; the determinism contract holds because the sample
1246    /// only runs when a Zapper is attached (the no-device path is unchanged).
1247    ///
1248    /// # Panics
1249    ///
1250    /// Panics if `port` is not in `0..=1`.
1251    pub fn set_zapper(&mut self, port: usize, x: u16, y: u16, trigger: bool) {
1252        if !matches!(
1253            self.bus.expansion_device(port),
1254            Some(InputDevice::Zapper(_))
1255        ) {
1256            self.bus.set_expansion_device(
1257                port,
1258                Some(InputDevice::Zapper(crate::input_device::ZapperState::new())),
1259            );
1260        }
1261        self.bus.set_zapper(port, x, y, trigger);
1262    }
1263
1264    /// Attach an NES Power Pad / Family Fun Fitness mat on `port` (typically
1265    /// port 1 / `$4017`) and set its live button mask (bit `i` = mat button
1266    /// `i+1`, 0..=11). Convenience wrapper that attaches the device if absent
1267    /// then updates it. Opt-in: the no-device path stays byte-identical.
1268    ///
1269    /// # Panics
1270    ///
1271    /// Panics if `port` is not in `0..=1`.
1272    pub fn set_power_pad(&mut self, port: usize, buttons: u16) {
1273        if !matches!(
1274            self.bus.expansion_device(port),
1275            Some(InputDevice::PowerPad(_))
1276        ) {
1277            self.bus.set_expansion_device(
1278                port,
1279                Some(InputDevice::PowerPad(
1280                    crate::input_device::PowerPadState::new(),
1281                )),
1282            );
1283        }
1284        self.bus.set_power_pad(port, buttons);
1285    }
1286
1287    /// v1.6.0 B3 — the latched standard-controller button bitmask for `port`
1288    /// (`0` = P1 / `$4016`, `1` = P2 / `$4017`; `2`/`3` are the Four Score
1289    /// players), in [`Buttons`](crate::Buttons) bit order (A = bit 0 .. Right =
1290    /// bit 7). Read-only and side-effect-free — it reads the latched state, not
1291    /// the shift register, so it never perturbs a controller poll. Exposed for
1292    /// the Lua `joypad.get` query.
1293    ///
1294    /// # Panics
1295    ///
1296    /// Panics if `port` is not in `0..=3`.
1297    #[must_use]
1298    pub fn controller_buttons(&self, port: usize) -> u8 {
1299        assert!(port <= 3, "controller port {port} out of range (0..=3)");
1300        self.bus.controller(port).buttons().bits()
1301    }
1302
1303    /// v1.2.0 Workstream D — attach a SNES-style serial mouse on `port` (0 =
1304    /// `$4016`, 1 = `$4017`) and set its movement / buttons / sensitivity.
1305    /// `(dx, dy)` are the signed per-frame deltas (clamped to +/-127 on latch);
1306    /// `sensitivity` is 0 (low) / 1 (medium) / 2 (high). Convenience wrapper
1307    /// that attaches the device if absent then updates it. Opt-in: the no-device
1308    /// path stays byte-identical.
1309    ///
1310    /// # Panics
1311    ///
1312    /// Panics if `port` is not in `0..=1`.
1313    pub fn set_snes_mouse(
1314        &mut self,
1315        port: usize,
1316        dx: i16,
1317        dy: i16,
1318        left: bool,
1319        right: bool,
1320        sensitivity: u8,
1321    ) {
1322        if !matches!(
1323            self.bus.expansion_device(port),
1324            Some(InputDevice::SnesMouse(_))
1325        ) {
1326            self.bus.set_expansion_device(
1327                port,
1328                Some(InputDevice::SnesMouse(
1329                    crate::input_device::SnesMouseState::new(),
1330                )),
1331            );
1332        }
1333        self.bus
1334            .set_snes_mouse(port, dx, dy, left, right, sensitivity);
1335    }
1336
1337    /// v1.2.0 Workstream D — attach a Famicom Family BASIC keyboard on `port`
1338    /// (typically port 1 / `$4017`) and set its pressed-key bitmap. `keys` is
1339    /// one byte per matrix row (`keys[row]` bits 0..=3 = column-half 0 keys,
1340    /// bits 4..=7 = column-half 1 keys); the frontend builds it from host keys.
1341    /// Convenience wrapper that attaches the device if absent then updates it.
1342    /// Opt-in: the no-device path stays byte-identical.
1343    ///
1344    /// # Panics
1345    ///
1346    /// Panics if `port` is not in `0..=1`.
1347    pub fn set_family_keyboard(&mut self, port: usize, keys: [u8; 9]) {
1348        if !matches!(
1349            self.bus.expansion_device(port),
1350            Some(InputDevice::FamilyKeyboard(_))
1351        ) {
1352            self.bus.set_expansion_device(
1353                port,
1354                Some(InputDevice::FamilyKeyboard(
1355                    crate::input_device::FamilyKeyboardState::new(),
1356                )),
1357            );
1358        }
1359        self.bus.set_family_keyboard(port, keys);
1360    }
1361
1362    /// v1.3.0 Workstream F1 — attach a Bandai **Family Trainer** mat on `port`
1363    /// and set its 12-button mask (bit `i` = mat button `i+1`). The Family
1364    /// Trainer is layout-equivalent to the Power Pad and reuses its scan; this
1365    /// attaches the [`InputDevice::FamilyTrainer`] variant (distinct from
1366    /// [`Self::set_power_pad`] so the selected device round-trips through a
1367    /// save-state). Opt-in: the no-device path stays byte-identical.
1368    ///
1369    /// # Panics
1370    ///
1371    /// Panics if `port` is not in `0..=1`.
1372    pub fn set_family_trainer(&mut self, port: usize, buttons: u16) {
1373        if !matches!(
1374            self.bus.expansion_device(port),
1375            Some(InputDevice::FamilyTrainer(_))
1376        ) {
1377            self.bus.set_expansion_device(
1378                port,
1379                Some(InputDevice::FamilyTrainer(
1380                    crate::input_device::PowerPadState::new(),
1381                )),
1382            );
1383        }
1384        self.bus.set_family_trainer(port, buttons);
1385    }
1386
1387    /// v1.3.0 Workstream F1 — attach a **Subor keyboard** on `port` and set its
1388    /// pressed-key bitmap (one byte per matrix row, like
1389    /// [`Self::set_family_keyboard`]). The Subor keyboard reuses the Family
1390    /// BASIC keyboard matrix scan; this attaches the
1391    /// [`InputDevice::SuborKeyboard`] variant. Opt-in: the no-device path stays
1392    /// byte-identical.
1393    ///
1394    /// # Panics
1395    ///
1396    /// Panics if `port` is not in `0..=1`.
1397    pub fn set_subor_keyboard(&mut self, port: usize, keys: [u8; 9]) {
1398        if !matches!(
1399            self.bus.expansion_device(port),
1400            Some(InputDevice::SuborKeyboard(_))
1401        ) {
1402            self.bus.set_expansion_device(
1403                port,
1404                Some(InputDevice::SuborKeyboard(
1405                    crate::input_device::FamilyKeyboardState::new(),
1406                )),
1407            );
1408        }
1409        self.bus.set_subor_keyboard(port, keys);
1410    }
1411
1412    /// v1.3.0 Workstream F1 — attach a **Konami Hyper Shot** on `port` and set
1413    /// its 4-button mask (bit 0 = P1 Run, 1 = P1 Jump, 2 = P2 Run, 3 = P2 Jump).
1414    /// Opt-in: the no-device path stays byte-identical.
1415    ///
1416    /// # Panics
1417    ///
1418    /// Panics if `port` is not in `0..=1`.
1419    pub fn set_konami_hyper_shot(&mut self, port: usize, buttons: u8) {
1420        if !matches!(
1421            self.bus.expansion_device(port),
1422            Some(InputDevice::KonamiHyperShot(_))
1423        ) {
1424            self.bus.set_expansion_device(
1425                port,
1426                Some(InputDevice::KonamiHyperShot(
1427                    crate::input_device::KonamiHyperShotState::new(),
1428                )),
1429            );
1430        }
1431        self.bus.set_konami_hyper_shot(port, buttons);
1432    }
1433
1434    /// v1.3.0 Workstream F1 — attach a **Bandai Hyper Shot** (Exciting Boxing
1435    /// punching bag) on `port` and set its 8-sensor mask (bits 0..=3 = the A=0
1436    /// group, bits 4..=7 = the A=1 group). Opt-in: the no-device path stays
1437    /// byte-identical.
1438    ///
1439    /// # Panics
1440    ///
1441    /// Panics if `port` is not in `0..=1`.
1442    pub fn set_bandai_hyper_shot(&mut self, port: usize, sensors: u8) {
1443        if !matches!(
1444            self.bus.expansion_device(port),
1445            Some(InputDevice::BandaiHyperShot(_))
1446        ) {
1447            self.bus.set_expansion_device(
1448                port,
1449                Some(InputDevice::BandaiHyperShot(
1450                    crate::input_device::BandaiHyperShotState::new(),
1451                )),
1452            );
1453        }
1454        self.bus.set_bandai_hyper_shot(port, sensors);
1455    }
1456
1457    /// v1.1.0 beta.1 (T-110-B4) — set (`Some`) or clear (`None`) a per-game
1458    /// **nametable mirroring override**, a load-time correction for ROMs whose
1459    /// iNES header carries the wrong mirroring flag (supplied by the frontend's
1460    /// game database). `None` (default) defers to the mapper — byte-identical,
1461    /// so the determinism / `AccuracyCoin` contract and the core test suites are
1462    /// unaffected (they never set it). Persisted in the save-state. Does not
1463    /// affect mappers with on-cart VRAM (4-screen).
1464    pub const fn set_mirroring_override(&mut self, m: Option<rustynes_mappers::Mirroring>) {
1465        self.bus.set_mirroring_override(m);
1466    }
1467
1468    /// Whether the loaded mapper's nametable mirroring is **hardwired** by the
1469    /// cartridge (solder pads / header bit) rather than controlled by the
1470    /// mapper's own registers at runtime.
1471    ///
1472    /// The frontend consults this before honoring a game-database mirroring
1473    /// correction: a static override is only valid for a hardwired board, and
1474    /// force-applying one to a mapper that switches mirroring itself (MMC1/3/5,
1475    /// `AxROM`, VRC, …) corrupts its rendering. See
1476    /// [`rustynes_mappers::Mapper::has_hardwired_mirroring`].
1477    #[must_use]
1478    pub fn mapper_has_hardwired_mirroring(&self) -> bool {
1479        self.bus.mapper_has_hardwired_mirroring()
1480    }
1481
1482    /// Write a byte directly into CPU work RAM (`$0000-$1FFF`). Used by the
1483    /// frontend's raw RAM cheats (GameShark-style); applied *after*
1484    /// [`Self::run_frame`], so the deterministic core run loop is unchanged
1485    /// (the determinism contract holds for the no-cheat path). No-op outside
1486    /// system RAM.
1487    pub fn poke_ram(&mut self, addr: u16, value: u8) {
1488        self.bus.poke_ram(addr, value);
1489    }
1490
1491    /// v1.7.0 "Forge" Workstream A1 — debugger writeback into the PPU bus
1492    /// (`$0000-$3FFF`): CHR pattern bytes (mapper `ppu_write`, a no-op on
1493    /// CHR-ROM), nametable tiles/attributes (mapper-absorbed, else CIRAM via the
1494    /// active mirroring), and palette RAM. The PPU-bus counterpart of
1495    /// [`Self::poke_ram`].
1496    ///
1497    /// Reached only through the frontend's gated post-frame poke path (the same
1498    /// caller-side, after-[`Self::run_frame`] stage the raw RAM cheats use), so
1499    /// the deterministic core run loop is unchanged and the no-edit path is
1500    /// byte-identical. `debug-hooks`-gated.
1501    #[cfg(feature = "debug-hooks")]
1502    pub fn debug_poke_ppu(&mut self, addr: u16, value: u8) {
1503        self.bus.debug_poke_ppu(addr, value);
1504    }
1505
1506    /// v1.7.0 "Forge" Workstream A1 — debugger writeback for one OAM byte
1507    /// (`idx` = 0..256: byte 0 = Y, 1 = tile, 2 = attributes, 3 = X per
1508    /// sprite). `debug-hooks`-gated; reached only through the gated post-frame
1509    /// poke path, so the default build is byte-identical.
1510    #[cfg(feature = "debug-hooks")]
1511    pub const fn poke_oam_byte(&mut self, idx: u8, value: u8) {
1512        self.bus.debug_poke_oam(idx, value);
1513    }
1514
1515    /// v1.7.0 "Forge" Workstream B (Lua API parity) — debugger/scripted
1516    /// writeback of the CPU register file (`a`/`x`/`y`/`s`/`p` bits/`pc`). The
1517    /// structured-state counterpart of [`Self::poke_ram`], backing the Lua
1518    /// `emu:setState(t)` field map (Mesen2 parity).
1519    ///
1520    /// Reached only through the frontend / script crate's gated post-frame poke
1521    /// path (the same caller-side, after-[`Self::run_frame`] stage the raw RAM
1522    /// cheats + the other `debug_poke_*` writebacks use), so the deterministic
1523    /// core run loop is unchanged and the no-edit path is byte-identical.
1524    /// `debug-hooks`-gated. `p` is taken as a raw status-bits byte (truncated to
1525    /// the defined flags, mirroring a `PLP` / save-state restore).
1526    #[cfg(feature = "debug-hooks")]
1527    // This is a runtime register-file mutator (the structured-state counterpart
1528    // of `poke_ram`); `const` adds no value and would needlessly constrain the
1529    // body, so the `missing_const_for_fn` suggestion is declined here.
1530    #[allow(clippy::missing_const_for_fn)]
1531    pub fn debug_set_cpu_state(&mut self, a: u8, x: u8, y: u8, s: u8, p_bits: u8, pc: u16) {
1532        self.cpu.a = a;
1533        self.cpu.x = x;
1534        self.cpu.y = y;
1535        self.cpu.s = s;
1536        self.cpu.p = rustynes_cpu::Status::from_bits_truncate(p_bits);
1537        self.cpu.pc = pc;
1538    }
1539
1540    /// Read a byte from the CPU address space (`$0000-$FFFF`) for inspection,
1541    /// **without** the register side effects of a real CPU read — reading
1542    /// `$2002` does not clear the VBL flag / address latch and `$2007` does not
1543    /// advance the PPU read buffer. Used by the debugger and the Lua scripting
1544    /// API (`emu.read`); it observes state without advancing the emulator,
1545    /// preserving determinism.
1546    #[must_use]
1547    pub fn peek(&mut self, addr: u16) -> u8 {
1548        self.bus.debug_peek_cpu(addr)
1549    }
1550
1551    /// v1.2.0 C3 (hd-pack) — side-effect-free read of the PPU bus
1552    /// (`$0000-$3FFF`): CHR pattern data, nametables, palette RAM. Used by the
1553    /// HD-pack compositor to hash a tile's 16 CHR bytes. Observes state without
1554    /// advancing the emulator, preserving determinism.
1555    #[cfg(feature = "hd-pack")]
1556    #[must_use]
1557    pub fn peek_ppu(&mut self, addr: u16) -> u8 {
1558        self.bus.debug_peek_ppu(addr)
1559    }
1560
1561    /// Add a Game Genie code (6 or 8 characters, case-insensitive) that
1562    /// substitutes a byte the CPU reads from PRG-ROM (`$8000-$FFFF`).
1563    ///
1564    /// Codes are a runtime overlay — they are **not** part of the save-state
1565    /// and do not perturb the determinism contract when none are active. With
1566    /// codes active, the substituted bytes are part of the deterministic
1567    /// input (record a movie with the same codes to reproduce a run).
1568    ///
1569    /// # Errors
1570    ///
1571    /// Returns [`GenieError`] if the code string cannot be decoded.
1572    pub fn add_genie_code(&mut self, code: &str) -> Result<(), GenieError> {
1573        self.bus.add_genie_code(code)
1574    }
1575
1576    /// Remove the active Game Genie code whose canonical (upper-case) string
1577    /// matches `code`. No-op if no such code is active.
1578    pub fn remove_genie_code(&mut self, code: &str) {
1579        self.bus.remove_genie_code(code);
1580    }
1581
1582    /// Remove all active Game Genie codes.
1583    pub fn clear_genie_codes(&mut self) {
1584        self.bus.clear_genie_codes();
1585    }
1586
1587    /// Iterate the active Game Genie codes (address-sorted).
1588    pub fn genie_codes(&self) -> impl Iterator<Item = &GenieCode> {
1589        self.bus.genie_codes()
1590    }
1591
1592    /// Drain into a slice; returns the count copied.  Excess samples are
1593    /// dropped if `out` is smaller than the buffered count.
1594    pub fn drain_audio_into(&mut self, out: &mut [f32]) -> usize {
1595        self.bus.drain_audio_into(out)
1596    }
1597
1598    /// SHA-256 of the ROM bytes this emulator was constructed from.
1599    ///
1600    /// Used by the frontend's save-state file layout (one directory per
1601    /// ROM, keyed by hex-encoded SHA-256). The hash is computed once at
1602    /// `from_rom` time; subsequent calls are O(1).
1603    #[must_use]
1604    pub const fn rom_sha256(&self) -> &[u8; 32] {
1605        &self.rom_sha256
1606    }
1607
1608    /// Truncated ROM hash tag stored in the save-state header.
1609    #[must_use]
1610    pub fn rom_hash_tag(&self) -> [u8; ROM_HASH_TAG_LEN] {
1611        let mut t = [0u8; ROM_HASH_TAG_LEN];
1612        t.copy_from_slice(&self.rom_sha256[..ROM_HASH_TAG_LEN]);
1613        t
1614    }
1615
1616    /// Encode the entire emulator state into a `.rns` snapshot blob.
1617    ///
1618    /// Includes a versioned container header and the four chip + bus
1619    /// sections (`CPU `, `PPU `, `APU `, `MAP `, `BUS `), plus an optional
1620    /// `THM ` thumbnail section (128x120 RGBA8 nearest-neighbor downsample
1621    /// of the current framebuffer). The thumbnail is for UI slot pickers
1622    /// only -- per ADR 0003 it is NOT part of the deterministic save-state
1623    /// contract.
1624    #[must_use]
1625    pub fn snapshot(&self) -> Vec<u8> {
1626        let tag = self.rom_hash_tag();
1627        // The bus knows how to emit BUS / PPU / APU / MAP sections; we
1628        // splice the CPU section in at the end.
1629        let mut out = self.bus.snapshot(tag);
1630        let cpu_body = self.cpu.snapshot();
1631        save_state::write_section(
1632            &mut out,
1633            save_state::tag::CPU,
1634            rustynes_cpu::CPU_SNAPSHOT_VERSION,
1635            &cpu_body,
1636        );
1637        // Optional thumbnail. Body layout: width(u16 le) + height(u16 le) +
1638        // length(u32 le) + raw RGBA8. The fixed THUMBNAIL_LEN is what we
1639        // emit but the body carries the dimensions explicitly so future
1640        // bumps (different thumbnail sizes) can be detected by the reader.
1641        let thumb = self.thumbnail();
1642        let mut body = Vec::with_capacity(2 + 2 + 4 + save_state::THUMBNAIL_LEN);
1643        body.extend_from_slice(
1644            &u16::try_from(save_state::THUMBNAIL_WIDTH)
1645                .unwrap()
1646                .to_le_bytes(),
1647        );
1648        body.extend_from_slice(
1649            &u16::try_from(save_state::THUMBNAIL_HEIGHT)
1650                .unwrap()
1651                .to_le_bytes(),
1652        );
1653        body.extend_from_slice(&u32::try_from(thumb.len()).unwrap().to_le_bytes());
1654        body.extend_from_slice(&thumb);
1655        save_state::write_section(
1656            &mut out,
1657            save_state::tag::THM,
1658            save_state::THUMBNAIL_VERSION,
1659            &body,
1660        );
1661        out
1662    }
1663
1664    /// v2.8.0 Phase 3 — [`Self::snapshot`] minus the `THM ` thumbnail
1665    /// section, encoded into a caller-owned reused buffer. The fast path
1666    /// for per-frame consumers (run-ahead, the netplay save-state ring):
1667    /// no allocation in steady state and no 61 KiB thumbnail build. The
1668    /// output parses with [`Self::restore`] / [`Self::restore_quiet`]
1669    /// exactly like a full snapshot (`THM ` is optional by format).
1670    pub fn snapshot_core_into(&self, out: &mut Vec<u8>) {
1671        let tag = self.rom_hash_tag();
1672        self.bus.snapshot_into(out, tag);
1673        let cpu_body = self.cpu.snapshot();
1674        save_state::write_section(
1675            out,
1676            save_state::tag::CPU,
1677            rustynes_cpu::CPU_SNAPSHOT_VERSION,
1678            &cpu_body,
1679        );
1680    }
1681
1682    /// Generate a 128x120 RGBA8 thumbnail of the current framebuffer.
1683    ///
1684    /// Nearest-neighbor downsample (sample every 2nd pixel of every 2nd row).
1685    /// The 1/4-resolution result is small enough that storing it in slot
1686    /// files is cheap (61,440 bytes uncompressed, ~10-20 KiB after the
1687    /// LZ4 path the rewind ring uses if it is ever wired through there).
1688    ///
1689    /// Per ADR 0003: NOT part of the deterministic save-state contract.
1690    /// Different builds may produce different pixel-perfect framebuffers
1691    /// at the same cycle if post-pass filters change.
1692    #[must_use]
1693    pub fn thumbnail(&self) -> Vec<u8> {
1694        // Native NES framebuffer is 256x240 RGBA8 = 245,760 bytes. Source
1695        // stride is 256 * 4 = 1024 bytes.
1696        const SRC_W: usize = 256;
1697        let fb = self.bus.framebuffer();
1698        let mut out = Vec::with_capacity(save_state::THUMBNAIL_LEN);
1699        for ty in 0..save_state::THUMBNAIL_HEIGHT {
1700            let sy = ty * 2;
1701            for tx in 0..save_state::THUMBNAIL_WIDTH {
1702                let sx = tx * 2;
1703                let i = (sy * SRC_W + sx) * 4;
1704                // Source framebuffer is always at least 256*240*4 bytes
1705                // (allocated by Ppu::new), so this index is in-bounds.
1706                out.extend_from_slice(&fb[i..i + 4]);
1707            }
1708        }
1709        debug_assert_eq!(out.len(), save_state::THUMBNAIL_LEN);
1710        out
1711    }
1712
1713    /// Extract a thumbnail from an `.rns` save-state blob without restoring
1714    /// it. Used by frontends to populate slot pickers.
1715    ///
1716    /// Returns `Ok(None)` if the blob is well-formed but contains no
1717    /// thumbnail section (older v0.9.0 slot files).
1718    ///
1719    /// # Errors
1720    ///
1721    /// Returns [`SnapshotError`] when the container header is malformed.
1722    pub fn extract_thumbnail(data: &[u8]) -> Result<Option<Vec<u8>>, SnapshotError> {
1723        let (_h, body_off) = save_state::parse_header(data)?;
1724        for s in save_state::SectionIter::new(&data[body_off..]) {
1725            let s = s?;
1726            if s.tag == save_state::tag::THM {
1727                // Body: width(u16) + height(u16) + length(u32) + bytes.
1728                if s.body.len() < 8 {
1729                    continue;
1730                }
1731                let w = u16::from_le_bytes([s.body[0], s.body[1]]) as usize;
1732                let h = u16::from_le_bytes([s.body[2], s.body[3]]) as usize;
1733                let n = u32::from_le_bytes([s.body[4], s.body[5], s.body[6], s.body[7]]) as usize;
1734                // Sanity: dimensions match what we currently emit, and the
1735                // declared length matches the body suffix.
1736                if w != save_state::THUMBNAIL_WIDTH
1737                    || h != save_state::THUMBNAIL_HEIGHT
1738                    || n != save_state::THUMBNAIL_LEN
1739                    || s.body.len() < 8 + n
1740                {
1741                    continue;
1742                }
1743                return Ok(Some(s.body[8..8 + n].to_vec()));
1744            }
1745        }
1746        Ok(None)
1747    }
1748
1749    /// Apply a previously [`Self::snapshot`]ed blob.
1750    ///
1751    /// Loading from a different ROM is allowed (the embedded hash tag is
1752    /// only a sanity check), but the result is undefined unless the chip
1753    /// section bodies are appropriate for the running mapper.
1754    ///
1755    /// # Errors
1756    ///
1757    /// Returns [`SnapshotError`] for malformed inputs.
1758    pub fn restore(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
1759        self.restore_inner(data, true)
1760    }
1761
1762    /// Shared restore body; `clear_rewind` distinguishes user-driven loads
1763    /// ([`Self::restore`] — the ring is invalidated) from same-timeline
1764    /// machine restores ([`Self::restore_quiet`] — the ring stays).
1765    fn restore_inner(&mut self, data: &[u8], clear_rewind: bool) -> Result<(), SnapshotError> {
1766        // Restore bus first — it consumes BUS / PPU / APU / MAP sections.
1767        self.bus.restore(data)?;
1768        // Then walk the sections again to find the CPU body.
1769        let (_h, body_off) = save_state::parse_header(data)?;
1770        let mut saw_cpu = false;
1771        for s in save_state::SectionIter::new(&data[body_off..]) {
1772            let s = s?;
1773            if s.tag == save_state::tag::CPU {
1774                if s.version != rustynes_cpu::CPU_SNAPSHOT_VERSION {
1775                    return Err(SnapshotError::VersionMismatch {
1776                        tag: save_state::tag_string(s.tag),
1777                        file_version: s.version,
1778                        chip_supports: rustynes_cpu::CPU_SNAPSHOT_VERSION,
1779                    });
1780                }
1781                self.cpu
1782                    .restore(s.body)
1783                    .map_err(|e| SnapshotError::SectionInvalid {
1784                        tag: save_state::tag_string(s.tag),
1785                        reason: format!("{e}"),
1786                    })?;
1787                saw_cpu = true;
1788            }
1789        }
1790        if !saw_cpu {
1791            return Err(SnapshotError::MissingSection("CPU ".into()));
1792        }
1793        // Loading invalidates the rewind ring (the new state is unrelated
1794        // to what was buffered before).
1795        if clear_rewind && let Some(r) = &mut self.rewind {
1796            r.clear();
1797        }
1798        Ok(())
1799    }
1800
1801    /// v2.8.0 Phase 3 — [`Self::restore`] WITHOUT clearing the rewind ring.
1802    ///
1803    /// For internal, machine-driven restores on the same timeline —
1804    /// run-ahead's per-frame rollback and netplay's rollback-resimulate —
1805    /// where the buffered rewind history remains exactly as valid as
1806    /// before. User-driven loads (save-state slots) keep using
1807    /// [`Self::restore`], which invalidates the ring.
1808    ///
1809    /// # Errors
1810    ///
1811    /// Returns [`SnapshotError`] for malformed inputs.
1812    pub fn restore_quiet(&mut self, data: &[u8]) -> Result<(), SnapshotError> {
1813        self.restore_inner(data, false)
1814    }
1815
1816    /// Enable the rewind ring buffer with default capacity (32 MiB) and
1817    /// keyframe period (60).
1818    pub fn enable_rewind(&mut self) {
1819        self.enable_rewind_with(REWIND_DEFAULT_MAX_BYTES, REWIND_DEFAULT_KEYFRAME_PERIOD);
1820    }
1821
1822    /// Enable rewind with explicit byte budget + keyframe period.
1823    pub fn enable_rewind_with(&mut self, max_bytes: usize, keyframe_period: u32) {
1824        self.rewind = Some(RewindRing::new(max_bytes, keyframe_period));
1825    }
1826
1827    /// Disable rewind and free the buffer.
1828    pub fn disable_rewind(&mut self) {
1829        self.rewind = None;
1830    }
1831
1832    /// Enable the per-CPU-instruction boot trace fixture with the given
1833    /// [`CpuBootTrace`](crate::cpu_boot_trace::CpuBootTrace).  Records past
1834    /// the trace's capacity are silently dropped (see
1835    /// [`CpuBootTrace::overflow`](crate::cpu_boot_trace::CpuBootTrace::overflow)).
1836    /// See `crates/rustynes-core/src/cpu_boot_trace.rs` for usage.
1837    #[cfg(feature = "cpu-boot-trace")]
1838    pub fn enable_cpu_boot_trace(&mut self, trace: crate::cpu_boot_trace::CpuBootTrace) {
1839        self.cpu_boot_trace = Some(trace);
1840    }
1841
1842    /// Take the accumulated CPU boot trace, leaving the slot empty.
1843    /// Returns `None` if tracing was never enabled.
1844    #[cfg(feature = "cpu-boot-trace")]
1845    #[must_use]
1846    pub const fn take_cpu_boot_trace(&mut self) -> Option<crate::cpu_boot_trace::CpuBootTrace> {
1847        self.cpu_boot_trace.take()
1848    }
1849
1850    /// Borrow the in-flight CPU boot trace for inspection.
1851    #[cfg(feature = "cpu-boot-trace")]
1852    #[must_use]
1853    pub const fn cpu_boot_trace(&self) -> Option<&crate::cpu_boot_trace::CpuBootTrace> {
1854        self.cpu_boot_trace.as_ref()
1855    }
1856
1857    /// Snapshot the current `(CPU register file + bus cycle + PPU
1858    /// position + opcode preview)` tuple into the CPU boot trace.
1859    ///
1860    /// Called from `run_frame` / `step_instruction` BEFORE the
1861    /// `Cpu::step` call.  The opcode + 2 operand bytes are peeked
1862    /// side-effect-free via `LockstepBus::debug_peek_cpu` so the
1863    /// trace is non-perturbing.
1864    ///
1865    /// No-op if the trace was never enabled.
1866    #[cfg(feature = "cpu-boot-trace")]
1867    fn cpu_boot_trace_record(&mut self) {
1868        use crate::cpu_boot_trace::CpuBootRecord;
1869        let Some(trace) = self.cpu_boot_trace.as_mut() else {
1870            return;
1871        };
1872        let cycle = self.bus.cycle();
1873        // Range pre-check: skip the peek bookkeeping entirely if this
1874        // cycle is outside the configured window.  The trace's own
1875        // `maybe_push` re-checks; the pre-check is the hot-path
1876        // optimisation.
1877        if !trace.config().contains(cycle) {
1878            return;
1879        }
1880        let pc = self.cpu.pc;
1881        let opcode = self.bus.debug_peek_cpu(pc);
1882        let op1 = self.bus.debug_peek_cpu(pc.wrapping_add(1));
1883        let op2 = self.bus.debug_peek_cpu(pc.wrapping_add(2));
1884        let ppu = self.bus.ppu();
1885        let mut flags: u8 = 0;
1886        // Mesen2 exposes `cpu.nmiFlag` and `cpu.irqFlag` (its
1887        // own pending-interrupt latches) but not the
1888        // armed-vs-pending distinction; flag bit 0 means "PPU is
1889        // driving NMI line high" which is observable on both
1890        // sides at instruction-fetch boundary.
1891        if ppu.nmi_line() {
1892            flags |= 0x01;
1893        }
1894        let rec = CpuBootRecord {
1895            cycle,
1896            frame: u32::try_from(ppu.frame()).unwrap_or(u32::MAX),
1897            scanline: ppu.scanline(),
1898            dot: ppu.dot(),
1899            pc,
1900            a: self.cpu.a,
1901            x: self.cpu.x,
1902            y: self.cpu.y,
1903            p: self.cpu.p.bits(),
1904            s: self.cpu.s,
1905            opcode,
1906            op1,
1907            op2,
1908            flags,
1909        };
1910        trace.maybe_push(rec);
1911    }
1912
1913    /// Push the current state onto the rewind ring. Frontends call this
1914    /// at the end of each completed frame.
1915    ///
1916    /// No-op if rewind is disabled.
1917    pub fn rewind_capture(&mut self) {
1918        if self.rewind.is_none() {
1919            return;
1920        }
1921        let frame = self.bus.ppu().frame();
1922        // v2.8.0 Phase 3 — the core fast path: no THM thumbnail (the ring
1923        // is never shown in a slot picker) and a reused buffer instead of
1924        // a fresh ~320 KiB allocation per frame. The ring still LZ4s /
1925        // delta-encodes the bytes itself.
1926        let mut buf = core::mem::take(&mut self.rewind_snap_buf);
1927        self.snapshot_core_into(&mut buf);
1928        if let Some(ring) = &mut self.rewind {
1929            ring.push(frame, &buf);
1930        }
1931        self.rewind_snap_buf = buf;
1932    }
1933
1934    /// Pop the most recent rewind entry and restore it. Returns `true` on
1935    /// success, `false` if the ring is empty (or rewind is disabled).
1936    pub fn rewind_step_back(&mut self) -> bool {
1937        let Some(ring) = self.rewind.as_mut() else {
1938            return false;
1939        };
1940        let Some(result) = ring.pop_back() else {
1941            return false;
1942        };
1943        let bytes = match result {
1944            Ok(b) => b,
1945            Err(_e) => return false,
1946        };
1947        // Restore but keep the ring alive (don't let `restore` clear it,
1948        // because the user is mid-rewind).
1949        let saved_ring = self.rewind.take();
1950        let r = self.restore(&bytes);
1951        // Reattach the (possibly cleared, but cleared-by-us is fine) ring.
1952        self.rewind = saved_ring;
1953        r.is_ok()
1954    }
1955
1956    /// Drop every buffered rewind entry. Called when the user releases
1957    /// the rewind key, so subsequent forward play overwrites — there's
1958    /// nothing to overwrite, but we want forward play to capture into a
1959    /// fresh ring rather than tail-of-old-history.
1960    pub fn rewind_clear(&mut self) {
1961        if let Some(r) = &mut self.rewind {
1962            r.clear();
1963        }
1964    }
1965
1966    /// `true` if rewind is enabled.
1967    #[must_use]
1968    pub const fn rewind_enabled(&self) -> bool {
1969        self.rewind.is_some()
1970    }
1971
1972    /// Number of buffered rewind entries.
1973    #[must_use]
1974    pub fn rewind_len(&self) -> usize {
1975        self.rewind.as_ref().map_or(0, RewindRing::len)
1976    }
1977
1978    /// Approximate memory used by the rewind ring, in bytes.
1979    #[must_use]
1980    pub fn rewind_bytes_used(&self) -> usize {
1981        self.rewind.as_ref().map_or(0, RewindRing::bytes_used)
1982    }
1983
1984    // -------------------------------------------------------------------
1985    // Debugger inspection API (Sprint 5-3). All read-only — these methods
1986    // MUST NOT advance emulator-visible state.
1987    // -------------------------------------------------------------------
1988
1989    /// Snapshot the CPU register file.
1990    #[must_use]
1991    #[allow(clippy::missing_const_for_fn)] // `is_jammed` is const-callable but we're const-conservative.
1992    pub fn cpu_snapshot(&self) -> CpuDebugView {
1993        let c = &self.cpu;
1994        CpuDebugView {
1995            a: c.a,
1996            x: c.x,
1997            y: c.y,
1998            s: c.s,
1999            pc: c.pc,
2000            p: c.p.bits(),
2001            jammed: c.is_jammed(),
2002            cycles: c.cycles,
2003        }
2004    }
2005
2006    /// Snapshot PPU state for the debugger.
2007    #[must_use]
2008    #[allow(clippy::missing_const_for_fn)]
2009    pub fn ppu_snapshot(&self) -> PpuDebugView {
2010        let ppu = self.bus.ppu();
2011        let regs = ppu.debug_registers();
2012        let (v, t, fine_x, w) = ppu.debug_scroll();
2013        PpuDebugView {
2014            dot: ppu.dot(),
2015            scanline: ppu.scanline(),
2016            frame: ppu.frame(),
2017            ctrl: regs[0],
2018            mask: regs[1],
2019            status: regs[2],
2020            oam_addr: regs[3],
2021            v,
2022            t,
2023            fine_x,
2024            w_toggle: w,
2025            sprite_size_16: ppu.sprite_size_16(),
2026            bg_pattern_base: ppu.bg_pattern_base(),
2027            sprite_pattern_base: ppu.sprite_pattern_base(),
2028            nmi_line: ppu.nmi_line(),
2029        }
2030    }
2031
2032    /// Snapshot APU channel outputs and IRQ flags.
2033    #[must_use]
2034    pub fn apu_snapshot(&self) -> ApuDebugView {
2035        let apu = self.bus.apu();
2036        ApuDebugView {
2037            pulse1: apu.pulse1_out(),
2038            pulse2: apu.pulse2_out(),
2039            triangle: apu.triangle_out(),
2040            noise: apu.noise_out(),
2041            dmc: apu.dmc_out(),
2042            external: apu.external_out(),
2043            frame_irq: apu.frame_irq_pending(),
2044            dmc_irq: apu.dmc_irq_pending(),
2045        }
2046    }
2047
2048    /// Set the APU per-channel enable mask (a UI playback overlay, NOT NES
2049    /// hardware state). Bit 0 = pulse 1, 1 = pulse 2, 2 = triangle, 3 = noise,
2050    /// 4 = DMC, 5 = external/mapper audio. A cleared bit mutes that channel.
2051    ///
2052    /// The default ([`rustynes_apu::CHANNEL_MASK_ALL`]) is byte-identical to
2053    /// the un-masked mixer — the deterministic per-frame audio is unchanged
2054    /// unless the frontend explicitly mutes a channel. This is never written
2055    /// into the save state, so it never affects determinism or round-trips.
2056    pub const fn set_apu_channel_mask(&mut self, mask: u8) {
2057        self.bus.apu_mut().set_channel_mask(mask);
2058    }
2059
2060    /// Current APU per-channel enable mask. See [`Self::set_apu_channel_mask`].
2061    #[must_use]
2062    pub const fn apu_channel_mask(&self) -> u8 {
2063        self.bus.apu().channel_mask()
2064    }
2065
2066    /// v1.4.0 Workstream C — set the APU per-channel output gain (a UI mixing
2067    /// overlay, NOT NES hardware state), generalizing [`Self::set_apu_channel_mask`].
2068    /// Index 0 = pulse 1, 1 = pulse 2, 2 = triangle, 3 = noise, 4 = DMC,
2069    /// 5 = external/mapper audio. Each gain is clamped to `0.0..=2.0`.
2070    ///
2071    /// The default ([`rustynes_apu::CHANNEL_GAIN_UNITY`], all `1.0`) is
2072    /// byte-identical to the un-scaled mixer — the deterministic per-frame audio
2073    /// is unchanged unless the frontend explicitly changes a gain. Never written
2074    /// into the save state, so it never affects determinism or round-trips.
2075    pub fn set_apu_channel_gain(&mut self, gain: [f32; 6]) {
2076        self.bus.apu_mut().set_channel_gain(gain);
2077    }
2078
2079    /// v2.1.3 — select the APU analog output-filter model (see
2080    /// [`rustynes_apu::FilterModel`]). The default
2081    /// [`rustynes_apu::FilterModel::NesRf`] (NES front-loader: 90 + 440 Hz HPF +
2082    /// 14 kHz LPF) is byte-identical to the pre-v2.1.3 output; `Famicom` (37 Hz
2083    /// HPF) and `Clean` (~10 Hz DC-block) drop the aggressive 440 Hz high-pass
2084    /// for a fuller low end. Tonal only — channel content is unchanged, and the
2085    /// model is never written into the save state (a frontend/config concern,
2086    /// re-applied at load), so determinism and round-trips are unaffected.
2087    pub fn set_apu_filter_model(&mut self, model: rustynes_apu::FilterModel) {
2088        self.bus.apu_mut().set_filter_model(model);
2089    }
2090
2091    /// Current APU per-channel output gain. See [`Self::set_apu_channel_gain`].
2092    #[must_use]
2093    pub const fn apu_channel_gain(&self) -> [f32; 6] {
2094        self.bus.apu().channel_gain()
2095    }
2096
2097    /// Borrow OAM (256 bytes = 64 sprites x 4 bytes).
2098    ///
2099    /// Returns a cloned `[u8; 256]` so the caller doesn't have to manage
2100    /// a borrow lifetime against `&self`.
2101    #[must_use]
2102    pub fn oam(&self) -> [u8; 256] {
2103        let mut out = [0u8; 256];
2104        let oam = self.bus.ppu().oam();
2105        out.copy_from_slice(&oam[..256]);
2106        out
2107    }
2108
2109    /// One OAM byte (`index` = `0..=255`), without copying the whole 256-byte
2110    /// array — for single-byte readers (e.g. the Lua `memory:read_oam`) that
2111    /// would otherwise pay a full `oam()` copy per access. Read-only.
2112    #[must_use]
2113    pub fn oam_byte(&self, index: u8) -> u8 {
2114        self.bus.ppu().oam()[index as usize]
2115    }
2116
2117    /// Borrow palette RAM (32 bytes).
2118    #[must_use]
2119    pub const fn palette_ram(&self) -> [u8; 32] {
2120        *self.bus.ppu().palette_ram()
2121    }
2122
2123    /// v1.1.0 beta.1 — install (`Some`) or clear (`None`) a custom 64-entry base
2124    /// palette loaded from a `.pal` file. A frontend presentation override: it
2125    /// re-tints the displayed RGBA framebuffer via the PPU's colour LUT but does
2126    /// not touch any logical core state. `None` (the default) is byte-identical to
2127    /// the built-in palette, so `AccuracyCoin` + the commercial oracle (which never
2128    /// set one) are unaffected. Not part of the save-state.
2129    pub const fn set_custom_palette(&mut self, base: Option<[[u8; 3]; 64]>) {
2130        self.bus.set_custom_palette(base);
2131    }
2132
2133    /// v1.7.0 "Forge" Workstream F3 — set the PPU extra-scanlines overclock: the
2134    /// number of EXTRA idle vblank scanlines the PPU inserts per frame (at the
2135    /// existing dot resolution, Mesen2 `UpdateTimings`). Each extra line is pure
2136    /// additional CPU run-time — it renders nothing, sets/clears no PPU flag, and
2137    /// fires no VBL/NMI/A12 event, so the visible image is unchanged. `0` (the
2138    /// default) is **byte-identical** to stock NES timing — `AccuracyCoin`, the
2139    /// commercial oracle, and nestest (which never set it) are unaffected.
2140    /// **Off by default**; a frontend config knob, not part of the save-state.
2141    /// Distinct from the CPU-multiplier overclock (a v2.0 timebase item).
2142    pub const fn set_extra_scanlines(&mut self, lines: u16) {
2143        self.bus.set_extra_scanlines(lines);
2144    }
2145
2146    /// v1.7.0 F3 — the configured extra-scanline overclock count (`0` = stock).
2147    #[must_use]
2148    pub const fn extra_scanlines(&self) -> u16 {
2149        self.bus.extra_scanlines()
2150    }
2151
2152    /// v2.1.8 A1 — enable or disable the specialized visible-scanline fast dot
2153    /// path (a pure performance optimization for the PPU's hottest per-dot
2154    /// case).
2155    ///
2156    /// The PPU dot FSM (`Ppu::tick`) is the emulator's single hottest function
2157    /// (~46% of a representative frame's self-time). This knob dispatches the
2158    /// common "clean" visible BG-render dots (visible scanline, dots `1..=256`,
2159    /// rendering stably enabled, no sub-dot disturbance) to a straight-line
2160    /// handler that runs the identical helper sequence with the statically-dead
2161    /// event branches pruned. **Off by default** and **byte-identical** to a
2162    /// build without it — proven bit-for-bit by the differential test
2163    /// (`fast_dotloop_diff`) and the full `AccuracyCoin` / visual-regression /
2164    /// nestest oracle. A frontend/config knob, NOT part of the save-state.
2165    pub const fn set_fast_dotloop(&mut self, enabled: bool) {
2166        self.bus.set_fast_dotloop(enabled);
2167    }
2168
2169    /// v2.1.8 A1 — whether the visible-scanline fast dot path is enabled
2170    /// (`false` = default, byte-identical to a build without it).
2171    #[must_use]
2172    pub const fn fast_dotloop(&self) -> bool {
2173        self.bus.fast_dotloop()
2174    }
2175
2176    /// v2.1.4 F2.3 — enable or disable the optional OAM-decay accuracy model.
2177    ///
2178    /// The 2C02's OAM is dynamic RAM refreshed by sprite evaluation; with rendering
2179    /// disabled for a while its un-refreshed 8-byte rows decay to a fixed garbage
2180    /// pattern. This models that (à la Mesen2's `EnableOamDecay`): a row un-touched
2181    /// for > 3000 CPU cycles decays on the next read. **Off by default** and
2182    /// **byte-identical** to a decay-free core when off — `AccuracyCoin`, the
2183    /// commercial oracle, and the visual regression suites are unaffected. It is
2184    /// NTSC/Dendy-only (PAL's refresh cadence masks decay). Deterministic when on
2185    /// (driven off the PPU's monotonic dot counter — no wall-clock / OS RNG), and a
2186    /// frontend/config knob re-applied on load, NOT part of the save-state (the
2187    /// in-flight per-row ages are serialized as a relative age so a rollback stays
2188    /// deterministic; the enable flag is not).
2189    pub const fn set_oam_decay(&mut self, enabled: bool) {
2190        self.bus.set_oam_decay(enabled);
2191    }
2192
2193    /// v2.1.4 F2.3 — whether the optional OAM-decay model is enabled (`false` =
2194    /// default, byte-identical to a decay-free core).
2195    #[must_use]
2196    pub const fn oam_decay_enabled(&self) -> bool {
2197        self.bus.oam_decay_enabled()
2198    }
2199
2200    /// v2.1.7 P5 — select the emulated 2C02 die revision (see [`PpuRevision`]).
2201    ///
2202    /// The [`PpuRevision::default`] ([`PpuRevision::Rp2c02H`]) models no extra
2203    /// quirks, so at the default this is inert and the core is **byte-identical**
2204    /// to a build without it — `AccuracyCoin`, the commercial oracle, and the
2205    /// visual / audio regression suites are unaffected. Selecting
2206    /// [`PpuRevision::Rp2c02G`] additionally models the OAMADDR (`$2003`)
2207    /// write-during-rendering OAM corruption glitch (*Huge Insect*). The
2208    /// selection is stored so a power-cycle re-applies it; it is config, not
2209    /// save-state (the corruption *state* it can arm already round-trips via the
2210    /// v6 PPU snapshot tail). Deterministic. A frontend/config knob re-applied on
2211    /// load, mirroring [`Nes::set_oam_decay`].
2212    pub const fn set_ppu_revision(&mut self, revision: PpuRevision) {
2213        self.bus.set_ppu_revision(revision);
2214    }
2215
2216    /// v2.1.7 P5 — the currently-selected 2C02 die revision (default
2217    /// [`PpuRevision::Rp2c02H`], byte-identical).
2218    #[must_use]
2219    pub const fn ppu_revision(&self) -> PpuRevision {
2220        self.bus.ppu_revision()
2221    }
2222
2223    /// v2.1.7 P5 — apply a power-up palette-RAM pattern (see [`PaletteInit`]).
2224    ///
2225    /// The 2C02's palette RAM is not cleared at power-on; this selects the
2226    /// power-up contents. [`PaletteInit::default`] ([`PaletteInit::Zeroed`])
2227    /// keeps the established all-zero power-up palette, so at the default this is
2228    /// **byte-identical**. [`PaletteInit::Blargg`] loads the canonical blargg
2229    /// power-up dump for software that samples uninitialized palette RAM. Writes
2230    /// only palette RAM (already part of the snapshot), so no snapshot change is
2231    /// needed; the selection is stored so a power-cycle re-applies it. Best
2232    /// called at power-on (palette RAM is preserved across a warm reset, like
2233    /// real hardware).
2234    pub const fn set_power_up_palette(&mut self, init: PaletteInit) {
2235        self.bus.set_power_up_palette(init);
2236    }
2237
2238    /// v2.1.7 P5 — the currently-selected power-up palette pattern (default
2239    /// [`PaletteInit::Zeroed`], byte-identical).
2240    #[must_use]
2241    pub const fn power_up_palette(&self) -> PaletteInit {
2242        self.bus.power_up_palette()
2243    }
2244
2245    /// v2.1.7 P5 — select the power-on work-RAM fill (see [`PowerOnRam`]).
2246    ///
2247    /// Applies the fill to the current 2 KiB work RAM (and open-bus latch)
2248    /// immediately and stores it so a power-cycle re-applies the same fill
2249    /// (`power_cycle == fresh boot`). [`PowerOnRam::default`]
2250    /// ([`PowerOnRam::Zeroed`]) is the established all-zero power-up state
2251    /// (**byte-identical**); the other variants are opt-in and deterministic,
2252    /// surfacing software that reads uninitialized RAM (*Final Fantasy* RNG,
2253    /// *River City Ransom*, *Cybernoid*). RAM is not consulted during reset, so
2254    /// applying it here is safe.
2255    pub fn set_power_on_ram(&mut self, ram: PowerOnRam) {
2256        self.bus.set_power_on_ram(ram);
2257    }
2258
2259    /// v2.1.7 P5 — the currently-selected power-on work-RAM fill (default
2260    /// [`PowerOnRam::Zeroed`], byte-identical).
2261    #[must_use]
2262    pub const fn power_on_ram(&self) -> PowerOnRam {
2263        self.bus.power_on_ram()
2264    }
2265
2266    /// v2.1.7 "Hardware Revisions & DMA Frontier" — select the emulated Ricoh
2267    /// 2A03 die revision, which gates the DMA unit's "unexpected DMA" extra
2268    /// halt-read on the DMC-halt-overlaps-OAM-halt cycle.
2269    ///
2270    /// **[`Cpu2A03Revision::Rp2A03G`] is the default** and is byte-identical to
2271    /// the core as it shipped before v2.1.7 (`AccuracyCoin` 141/141, nestest
2272    /// 0-diff, every committed DMA oracle ROM `Passed`).
2273    /// [`Cpu2A03Revision::Rp2A03H`] is a purely additive, opt-in accuracy knob
2274    /// that omits the extra read; its direction is an **unverified hypothesis**
2275    /// (no public reference emulator or test ROM models the 2A03 die-revision
2276    /// DMA difference — see the type docs and ADR 0033). It is deterministic and
2277    /// a config knob re-applied on load, NOT part of the save-state.
2278    pub const fn set_cpu_2a03_revision(&mut self, revision: Cpu2A03Revision) {
2279        self.bus.set_cpu_2a03_revision(revision);
2280    }
2281
2282    /// v2.1.7 — the configured 2A03 die revision (default
2283    /// [`Cpu2A03Revision::Rp2A03G`], byte-identical to the pre-v2.1.7 core).
2284    #[must_use]
2285    pub const fn cpu_2a03_revision(&self) -> Cpu2A03Revision {
2286        self.bus.cpu_2a03_revision()
2287    }
2288
2289    /// Mapper debug info (bank registers, IRQ counters, mirroring, ...).
2290    #[must_use]
2291    pub fn mapper_info(&self) -> MapperDebugView {
2292        self.bus.mapper_debug_info()
2293    }
2294
2295    /// v1.4.0 Workstream C — the loaded mapper's on-cart expansion-audio chip
2296    /// name (e.g. `"VRC6"`, `"VRC7 (OPLL)"`, `"MMC5"`, `"Namco 163"`,
2297    /// `"Sunsoft 5B"`, `"FDS"`), or `None` when the board has no expansion audio
2298    /// (or the `mapper-audio` feature is compiled out). Used by the frontend to
2299    /// show the expansion-channel volume slider only when present, with a label.
2300    ///
2301    /// Discovery is dynamic: it consults the cached [`rustynes_mappers::MapperCaps`]
2302    /// `audio` flag (true only when the mapper overrides `mix_audio` with the
2303    /// feature on) and the mapper id to name the chip family.
2304    #[must_use]
2305    pub fn expansion_audio_chip(&self) -> Option<&'static str> {
2306        if !self.bus.mapper_caps().audio {
2307            return None;
2308        }
2309        let id = self.bus.mapper_debug_info().mapper_id;
2310        Some(match id {
2311            5 => "MMC5",
2312            19 | 210 => "Namco 163",
2313            20 => "FDS",
2314            24 | 26 => "VRC6",
2315            69 => "Sunsoft 5B",
2316            85 => "VRC7 (OPLL)",
2317            // The board overrides `mix_audio` but isn't one of the named
2318            // families above — surface a generic label so the slider still
2319            // appears (e.g. a future expansion-audio mapper).
2320            _ => "Expansion audio",
2321        })
2322    }
2323
2324    /// Side-effect-free CPU bus peek (for the hex viewer).
2325    pub fn cpu_bus_peek(&mut self, addr: u16) -> u8 {
2326        self.bus.debug_peek_cpu(addr)
2327    }
2328
2329    /// Side-effect-free PPU bus peek (for the hex viewer + visualizers).
2330    pub fn ppu_bus_peek(&mut self, addr: u16) -> u8 {
2331        self.bus.debug_peek_ppu(addr)
2332    }
2333
2334    /// Render the 256 tiles of a CHR pattern table as RGBA8 (128x128).
2335    ///
2336    /// `table` selects which of the two pattern tables: 0 -> `$0000`,
2337    /// 1 -> `$1000`. Uses BG palette 0 ($3F00-$3F03) for grayscale-ish
2338    /// rendering. ~80 KiB cloned; only call when the PPU pattern viewer
2339    /// is open.
2340    pub fn pattern_table_rgba(&mut self, table: u8) -> Vec<u8> {
2341        const TILE_W: usize = 8;
2342        const SHEET_W: usize = 128;
2343        const SHEET_H: usize = 128;
2344        let base: u16 = if table & 1 == 0 { 0 } else { 0x1000 };
2345        let mut out = vec![0u8; SHEET_W * SHEET_H * 4];
2346        for tile_y in 0..16u16 {
2347            for tile_x in 0..16u16 {
2348                let tile_index = tile_y * 16 + tile_x;
2349                for row in 0..8u16 {
2350                    let lo = self.ppu_bus_peek(base + tile_index * 16 + row);
2351                    let hi = self.ppu_bus_peek(base + tile_index * 16 + row + 8);
2352                    for col in 0..8u16 {
2353                        let bit = 7 - col;
2354                        let p = ((hi >> bit) & 1) << 1 | ((lo >> bit) & 1);
2355                        let palette_byte = self.ppu_bus_peek(0x3F00 + u16::from(p));
2356                        let rgba = rustynes_ppu::nes_color_to_rgba(palette_byte & 0x3F);
2357                        let px = usize::from(tile_x) * TILE_W + usize::from(col);
2358                        let py = usize::from(tile_y) * TILE_W + usize::from(row);
2359                        let off = (py * SHEET_W + px) * 4;
2360                        out[off..off + 4].copy_from_slice(&rgba);
2361                    }
2362                }
2363            }
2364        }
2365        out
2366    }
2367
2368    /// Render a nametable as RGBA8 (256x240).
2369    ///
2370    /// `nt` selects 0..=3 logical nametable. Uses the current
2371    /// BG pattern table base, attribute palette, and CHR data.
2372    pub fn nametable_rgba(&mut self, nt: u8) -> Vec<u8> {
2373        const FB_W: usize = 256;
2374        const FB_H: usize = 240;
2375        let nt = nt & 0x03;
2376        let nt_base = 0x2000u16 + u16::from(nt) * 0x400;
2377        let attr_base = nt_base + 0x3C0;
2378        let bg_base = self.bus.ppu().bg_pattern_base();
2379        let mut out = vec![0u8; FB_W * FB_H * 4];
2380        for ty in 0..30u16 {
2381            for tx in 0..32u16 {
2382                let nt_addr = nt_base + ty * 32 + tx;
2383                let tile_idx = self.ppu_bus_peek(nt_addr);
2384                let attr_addr = attr_base + (ty / 4) * 8 + (tx / 4);
2385                let attr_byte = self.ppu_bus_peek(attr_addr);
2386                let shift = ((ty & 2) << 1) | (tx & 2);
2387                let palette = u16::from((attr_byte >> shift) & 0x03);
2388                for row in 0..8u16 {
2389                    let lo = self.ppu_bus_peek(bg_base + u16::from(tile_idx) * 16 + row);
2390                    let hi = self.ppu_bus_peek(bg_base + u16::from(tile_idx) * 16 + row + 8);
2391                    for col in 0..8u16 {
2392                        let bit = 7 - col;
2393                        let p = ((hi >> bit) & 1) << 1 | ((lo >> bit) & 1);
2394                        let final_idx = if p == 0 {
2395                            self.ppu_bus_peek(0x3F00)
2396                        } else {
2397                            self.ppu_bus_peek(0x3F00 + palette * 4 + u16::from(p))
2398                        };
2399                        let rgba = rustynes_ppu::nes_color_to_rgba(final_idx & 0x3F);
2400                        let px = usize::from(tx * 8 + col);
2401                        let py = usize::from(ty * 8 + row);
2402                        let off = (py * FB_W + px) * 4;
2403                        out[off..off + 4].copy_from_slice(&rgba);
2404                    }
2405                }
2406            }
2407        }
2408        out
2409    }
2410}
2411
2412fn sha256_of(bytes: &[u8]) -> [u8; 32] {
2413    let mut h = Sha256::new();
2414    h.update(bytes);
2415    let out = h.finalize();
2416    let mut a = [0u8; 32];
2417    a.copy_from_slice(&out);
2418    a
2419}
2420
2421#[cfg(test)]
2422mod tests {
2423    use super::*;
2424
2425    /// 16-byte iNES header for a synthetic NROM ROM with `prg_kib`/`chr_kib`
2426    /// content, vertical mirroring.
2427    fn synth_nrom(prg_kib: usize, chr_kib: usize) -> Vec<u8> {
2428        let mut bytes = Vec::with_capacity(16 + prg_kib * 1024 + chr_kib * 1024);
2429        bytes.extend_from_slice(b"NES\x1A");
2430        bytes.push(u8::try_from(prg_kib / 16).unwrap());
2431        bytes.push(u8::try_from(chr_kib / 8).unwrap());
2432        bytes.push(0); // flags6
2433        bytes.push(0); // flags7
2434        bytes.extend_from_slice(&[0u8; 8]);
2435
2436        // PRG payload: a tiny program at $C000 that loops forever (JMP $C000).
2437        // Since the reset vector reads $FFFC/D, we set those bytes too.
2438        let mut prg = vec![0u8; prg_kib * 1024];
2439        if prg_kib >= 16 {
2440            // 16 KiB PRG: $C000-$FFFF maps to bytes 0..$4000 of PRG.
2441            // JMP $C000 -> $4C $00 $C0
2442            prg[0] = 0x4C;
2443            prg[1] = 0x00;
2444            prg[2] = 0xC0;
2445            // Reset vector at $FFFC/D = end-of-PRG offsets.
2446            let len = prg.len();
2447            prg[len - 4] = 0x00;
2448            prg[len - 3] = 0xC0;
2449            // NMI vector at $FFFA/B: same.
2450            prg[len - 6] = 0x00;
2451            prg[len - 5] = 0xC0;
2452            // IRQ vector at $FFFE/F: same.
2453            prg[len - 2] = 0x00;
2454            prg[len - 1] = 0xC0;
2455        }
2456        bytes.extend_from_slice(&prg);
2457        bytes.extend_from_slice(&vec![0u8; chr_kib * 1024]);
2458        bytes
2459    }
2460
2461    /// Synthetic NES 2.0 NROM with console type Vs. System and a byte-13 Vs.
2462    /// PPU type (low nibble).
2463    fn synth_vs_nrom(vs_ppu_low_nibble: u8) -> Vec<u8> {
2464        let mut rom = synth_nrom(16, 8);
2465        // Upgrade the header to NES 2.0 + console type Vs. System.
2466        rom[7] = 0x09; // bits 2-3 = 10 (NES 2.0), bits 0-1 = 01 (Vs. System)
2467        rom[13] = vs_ppu_low_nibble & 0x0F;
2468        rom
2469    }
2470
2471    #[test]
2472    fn nes_cart_4016_read_is_byte_identical_with_and_without_vs_inputs() {
2473        // On a normal NES cart the Vs. DIP/coin/service overlay is a no-op, so
2474        // a $4016/$4017 read is byte-for-byte identical regardless of the Vs.
2475        // input state. Compare two freshly-built buses in lockstep.
2476        let rom = synth_nrom(16, 8);
2477        let mut a = Nes::from_rom(&rom).unwrap();
2478        let mut b = Nes::from_rom(&rom).unwrap();
2479        assert!(!a.is_vs_system());
2480        // Crank the Vs. inputs on `b` only.
2481        b.set_vs_dip(0xFF);
2482        b.insert_coin(0);
2483        b.insert_coin(1);
2484        b.set_vs_service(true);
2485        for addr in [0x4016u16, 0x4017, 0x4016, 0x4017] {
2486            assert_eq!(
2487                a.bus_mut().raw_cpu_read(addr),
2488                b.bus_mut().raw_cpu_read(addr),
2489                "Vs. inputs leaked into a normal-cart read of {addr:#06X}"
2490            );
2491        }
2492    }
2493
2494    /// v1.6.0 Workstream A3 — the `TAStudio` lag-log flag: a frame in which the
2495    /// program never reads `$4016`/`$4017` is a lag frame; a controller read
2496    /// marks the frame polled; and the flag resets at the top of each frame.
2497    #[cfg(feature = "debug-hooks")]
2498    #[test]
2499    fn lag_flag_tracks_controller_reads_per_frame() {
2500        // synth_nrom is a pure `JMP $C000` loop — it never polls input.
2501        let rom = synth_nrom(16, 8);
2502        let mut nes = Nes::from_rom(&rom).unwrap();
2503
2504        // A frame of pure JMP never reads a controller port => lag frame.
2505        nes.run_frame();
2506        assert!(
2507            !nes.was_input_polled_this_frame(),
2508            "a frame with no $4016/$4017 read must be a lag frame"
2509        );
2510
2511        // A controller-port read marks the (current) frame as polled.
2512        let _ = nes.bus_mut().raw_cpu_read(0x4016);
2513        assert!(
2514            nes.was_input_polled_this_frame(),
2515            "a $4016 read must mark the frame polled"
2516        );
2517
2518        // $4017 also counts, and the next frame's clear resets the flag.
2519        nes.run_frame();
2520        assert!(
2521            !nes.was_input_polled_this_frame(),
2522            "the flag must reset at the top of each frame"
2523        );
2524        let _ = nes.bus_mut().raw_cpu_read(0x4017);
2525        assert!(
2526            nes.was_input_polled_this_frame(),
2527            "a $4017 read must also mark the frame polled"
2528        );
2529    }
2530
2531    #[test]
2532    fn vs_dip_switches_read_through_4016_and_4017() {
2533        // 2C03 Vs. cart (low nibble 0).
2534        let rom = synth_vs_nrom(0x0);
2535        let mut nes = Nes::from_rom(&rom).unwrap();
2536        assert!(nes.is_vs_system());
2537        // DIP = 0b1010_1010: sw2,4,6,8 on; sw1,3,5,7 off.
2538        nes.set_vs_dip(0b1010_1010);
2539        let v16 = nes.bus_mut().raw_cpu_read(0x4016);
2540        // $4016: DIP sw1 -> bit3 (off), sw2 -> bit4 (on).
2541        assert_eq!(v16 & 0x08, 0x00, "DIP sw1 off");
2542        assert_eq!(v16 & 0x10, 0x10, "DIP sw2 on");
2543        let v17 = nes.bus_mut().raw_cpu_read(0x4017);
2544        // $4017: DIP sw3..8 -> bits 2..7. DIP bits 2..7 = 0b101010.
2545        assert_eq!(v17 & 0xFC, 0b1010_1000 & 0xFC);
2546    }
2547
2548    #[test]
2549    fn vs_coin_and_service_read_through_4016() {
2550        let rom = synth_vs_nrom(0x0);
2551        let mut nes = Nes::from_rom(&rom).unwrap();
2552        nes.set_vs_dip(0);
2553        // Coin acceptor #1 -> $4016 bit 5.
2554        nes.insert_coin(0);
2555        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x20, 0x20);
2556        // Acceptor #2 -> bit 6.
2557        nes.insert_coin(1);
2558        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x60, 0x60);
2559        nes.clear_coin();
2560        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x60, 0x00);
2561        // Service button -> bit 2.
2562        nes.set_vs_service(true);
2563        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x04, 0x04);
2564        nes.set_vs_service(false);
2565        assert_eq!(nes.bus_mut().raw_cpu_read(0x4016) & 0x04, 0x00);
2566    }
2567
2568    #[test]
2569    fn game_genie_substitutes_on_cpu_read_path() {
2570        // 16 KiB NROM; plant the Zelda code's compare byte (0x22) at the PRG
2571        // address it targets ($9F41 -> $8000-$BFFF window -> PRG offset $1F41).
2572        let mut rom = synth_nrom(16, 8);
2573        rom[16 + 0x1F41] = 0x22;
2574        let mut nes = Nes::from_rom(&rom).expect("synthetic NROM parses");
2575
2576        // No codes active: reads are the original byte (determinism contract).
2577        assert_eq!(nes.bus_mut().debug_peek_cpu(0x9F41), 0x22);
2578        assert_eq!(nes.bus_mut().peek_cpu(0x9F41), 0x22);
2579        assert_eq!(nes.genie_codes().count(), 0);
2580
2581        // 8-char code substitutes only when the original matches compare (0x22),
2582        // on BOTH the production read path and the debugger peek path.
2583        nes.add_genie_code("YYKPOYZZ").expect("valid 8-char code");
2584        assert_eq!(
2585            nes.bus_mut().debug_peek_cpu(0x9F41),
2586            0x77,
2587            "debug peek substituted"
2588        );
2589        assert_eq!(
2590            nes.bus_mut().peek_cpu(0x9F41),
2591            0x77,
2592            "production read substituted"
2593        );
2594        assert_eq!(
2595            nes.bus_mut().debug_peek_cpu(0x9F40),
2596            0x00,
2597            "other address untouched"
2598        );
2599
2600        // Removal (case-insensitive) restores the original byte.
2601        nes.remove_genie_code("yykpoyzz");
2602        assert_eq!(nes.bus_mut().debug_peek_cpu(0x9F41), 0x22);
2603
2604        // 6-char code (no compare) always substitutes; $91D9 -> data 0xAD.
2605        nes.add_genie_code("SXIOPO").expect("valid 6-char code");
2606        assert_eq!(nes.bus_mut().debug_peek_cpu(0x91D9), 0xAD);
2607        nes.clear_genie_codes();
2608        assert_eq!(nes.bus_mut().debug_peek_cpu(0x91D9), 0x00);
2609
2610        // A malformed code is rejected without mutating state.
2611        assert!(nes.add_genie_code("BADCODE!").is_err());
2612    }
2613
2614    #[test]
2615    fn poke_ram_writes_system_ram_and_ignores_rom() {
2616        let rom = synth_nrom(16, 8);
2617        let mut nes = Nes::from_rom(&rom).expect("synthetic NROM parses");
2618        nes.poke_ram(0x0042, 0xAB);
2619        assert_eq!(nes.bus_mut().debug_peek_cpu(0x0042), 0xAB);
2620        // Mirrored every $800 within $0000-$1FFF.
2621        assert_eq!(nes.bus_mut().debug_peek_cpu(0x0842), 0xAB);
2622        // A poke outside system RAM is a no-op (no panic; ROM space untouched).
2623        nes.poke_ram(0x8000, 0xFF);
2624        assert_ne!(nes.bus_mut().debug_peek_cpu(0x8000), 0xFF);
2625    }
2626
2627    #[test]
2628    fn nes_set_buttons_then_strobe_reads_bits_in_order() {
2629        // T-51-005: end-to-end controller plumbing — the bus must shift the
2630        // latched button state out via $4016 in canonical order.
2631        //
2632        // Session-24 / Phase 3 update: `$4016` writes are now deferred
2633        // (committed at the next M2-low boundary inside
2634        // `tick_one_cpu_cycle`).  Direct-API callers that bypass CPU
2635        // stepping must tick the bus between the strobe pulse and the
2636        // shift-out reads so the buffered write commits.  Two ticks
2637        // are sufficient (one for the pending=1 commit, one as a
2638        // margin in case the test's first write landed on the pending=2
2639        // path).
2640        use rustynes_cpu::Bus as _;
2641        let rom = synth_nrom(16, 8);
2642        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
2643        nes.set_buttons(0, Buttons::A | Buttons::SELECT | Buttons::DOWN);
2644
2645        // Pulse the strobe latch (write 1 then 0 to $4016), driving the
2646        // bus enough cycles between writes for the deferred-write
2647        // commit to land.
2648        nes.bus_mut().cpu_write(0x4016, 1);
2649        nes.bus_mut().tick_one_cpu_cycle();
2650        nes.bus_mut().tick_one_cpu_cycle();
2651        nes.bus_mut().cpu_write(0x4016, 0);
2652        nes.bus_mut().tick_one_cpu_cycle();
2653        nes.bus_mut().tick_one_cpu_cycle();
2654
2655        // 8 reads of $4016 should yield A, B, Select, Start, Up, Down, Left, Right.
2656        let expected = [1u8, 0, 1, 0, 0, 1, 0, 0];
2657        for &want in &expected {
2658            let v = nes.bus_mut().cpu_read(0x4016) & 1;
2659            assert_eq!(v, want);
2660        }
2661    }
2662
2663    #[test]
2664    fn nes_set_buttons_port1_reads_via_4017_in_order() {
2665        // T-71-004 (Phase 7): player 2 plumbing. The strobe latch is shared
2666        // (writing `$4016` strobes BOTH pads); player 2 shifts out on `$4017`.
2667        // Mirrors `nes_set_buttons_then_strobe_reads_bits_in_order` for port 1.
2668        use rustynes_cpu::Bus as _;
2669        let rom = synth_nrom(16, 8);
2670        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
2671        nes.set_buttons(1, Buttons::B | Buttons::START | Buttons::RIGHT);
2672
2673        nes.bus_mut().cpu_write(0x4016, 1);
2674        nes.bus_mut().tick_one_cpu_cycle();
2675        nes.bus_mut().tick_one_cpu_cycle();
2676        nes.bus_mut().cpu_write(0x4016, 0);
2677        nes.bus_mut().tick_one_cpu_cycle();
2678        nes.bus_mut().tick_one_cpu_cycle();
2679
2680        // A, B, Select, Start, Up, Down, Left, Right.
2681        let expected = [0u8, 1, 0, 1, 0, 0, 0, 1];
2682        for (i, &want) in expected.iter().enumerate() {
2683            let v = nes.bus_mut().cpu_read(0x4017) & 1;
2684            assert_eq!(v, want, "$4017 read #{i}");
2685        }
2686    }
2687
2688    #[test]
2689    fn nes_restrobe_relatches_current_buttons() {
2690        // T-71-004 (Phase 7): a fresh strobe re-samples the live button state
2691        // through the full bus (the per-`Controller` unit test in
2692        // `controller.rs` covers this at the chip level; this confirms it end
2693        // to end via `Nes::set_buttons` + `$4016`).
2694        use rustynes_cpu::Bus as _;
2695        let rom = synth_nrom(16, 8);
2696        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
2697
2698        let strobe = |nes: &mut Nes| {
2699            nes.bus_mut().cpu_write(0x4016, 1);
2700            nes.bus_mut().tick_one_cpu_cycle();
2701            nes.bus_mut().tick_one_cpu_cycle();
2702            nes.bus_mut().cpu_write(0x4016, 0);
2703            nes.bus_mut().tick_one_cpu_cycle();
2704            nes.bus_mut().tick_one_cpu_cycle();
2705        };
2706
2707        nes.set_buttons(0, Buttons::A);
2708        strobe(&mut nes);
2709        assert_eq!(nes.bus_mut().cpu_read(0x4016) & 1, 1, "A latched pressed");
2710
2711        // Change state, then re-strobe: the new state must be visible.
2712        nes.set_buttons(0, Buttons::empty());
2713        strobe(&mut nes);
2714        assert_eq!(nes.bus_mut().cpu_read(0x4016) & 1, 0, "A latched released");
2715    }
2716
2717    #[test]
2718    fn reading_4015_does_not_refresh_external_open_bus() {
2719        // T-72-006 (Phase 7): `$4015` reads return the APU status but do NOT
2720        // drive the external data bus (the APU status port is internal to the
2721        // 2A03 package). So a `$4015` read must leave the open-bus latch
2722        // unchanged — a subsequent open-bus-region read returns the prior
2723        // floating value, not the APU status. Per nesdev "Open bus behavior"
2724        // + AccuracyCoin `CPU Behavior :: Open Bus` Test 7.
2725        use rustynes_cpu::Bus as _;
2726        let rom = synth_nrom(16, 8);
2727        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
2728
2729        // Drive a known value onto the external bus via a normal RAM read.
2730        nes.bus_mut().cpu_write(0x0010, 0xAB);
2731        assert_eq!(nes.bus_mut().cpu_read(0x0010), 0xAB);
2732        // $4018-$401F is open-bus region: returns (and re-latches) the value.
2733        assert_eq!(
2734            nes.bus_mut().cpu_read(0x4018),
2735            0xAB,
2736            "open-bus latch holds 0xAB"
2737        );
2738
2739        // Read $4015 — must NOT refresh the external latch.
2740        let _ = nes.bus_mut().cpu_read(0x4015);
2741
2742        // The latch is still 0xAB, not whatever APU status $4015 returned.
2743        assert_eq!(
2744            nes.bus_mut().cpu_read(0x4018),
2745            0xAB,
2746            "$4015 read must not drive the external data bus"
2747        );
2748    }
2749
2750    #[test]
2751    fn nes_from_rom_constructs_and_resets() {
2752        let rom = synth_nrom(16, 8);
2753        let nes = Nes::from_rom(&rom).expect("parse + boot");
2754        assert_eq!(nes.cpu().pc, 0xC000);
2755    }
2756
2757    #[test]
2758    fn power_on_randomization_is_opt_in_seeded_and_deterministic() {
2759        // T-72-005 (Phase 7): the default path leaves work RAM zeroed; the
2760        // seeded constructor randomizes it deterministically.
2761        let rom = synth_nrom(16, 8);
2762
2763        // Default: RAM is zeroed.
2764        let mut default = Nes::from_rom(&rom).expect("parse + boot");
2765        for addr in (0x0000u16..0x0800).step_by(0x40) {
2766            assert_eq!(default.cpu_bus_peek(addr), 0, "default RAM must be zero");
2767        }
2768
2769        // Seeded: RAM is not all-zero.
2770        let mut a = Nes::from_rom_with_power_on_seed(&rom, 1).expect("parse + boot");
2771        let dump_a: Vec<u8> = (0x0000u16..0x0100).map(|x| a.cpu_bus_peek(x)).collect();
2772        assert!(
2773            dump_a.iter().any(|&b| b != 0),
2774            "seeded RAM must not be all zero"
2775        );
2776
2777        // Same seed -> identical RAM.
2778        let mut a2 = Nes::from_rom_with_power_on_seed(&rom, 1).expect("parse + boot");
2779        let dump_a2: Vec<u8> = (0x0000u16..0x0100).map(|x| a2.cpu_bus_peek(x)).collect();
2780        assert_eq!(
2781            dump_a, dump_a2,
2782            "same seed must yield identical power-on RAM"
2783        );
2784
2785        // Different seed -> different RAM.
2786        let mut b = Nes::from_rom_with_power_on_seed(&rom, 0xDEAD_BEEF).expect("parse + boot");
2787        let dump_b: Vec<u8> = (0x0000u16..0x0100).map(|x| b.cpu_bus_peek(x)).collect();
2788        assert_ne!(dump_a, dump_b, "different seeds should differ");
2789    }
2790
2791    #[test]
2792    fn power_on_config_defaults_byte_identical_and_variants_deterministic() {
2793        // v2.1.7 P5 — the PowerOnConfig surface. Default (Zeroed) must match the
2794        // plain constructor; Filled + Seeded must be deterministic and distinct.
2795        let rom = synth_nrom(16, 8);
2796
2797        // Default config == from_rom (byte-identical work RAM).
2798        let mut zeroed =
2799            Nes::from_rom_with_power_on_config(&rom, PowerOnConfig::default()).expect("boot");
2800        assert_eq!(zeroed.power_on_ram(), PowerOnRam::Zeroed);
2801        for addr in (0x0000u16..0x0800).step_by(0x40) {
2802            assert_eq!(zeroed.cpu_bus_peek(addr), 0, "Zeroed config: RAM zero");
2803        }
2804
2805        // Filled(0xFF): every work-RAM byte is 0xFF, deterministically.
2806        let mut filled = Nes::from_rom_with_power_on_config(
2807            &rom,
2808            PowerOnConfig {
2809                ram: PowerOnRam::Filled(0xFF),
2810            },
2811        )
2812        .expect("boot");
2813        assert_eq!(filled.power_on_ram(), PowerOnRam::Filled(0xFF));
2814        for addr in (0x0000u16..0x0800).step_by(0x40) {
2815            assert_eq!(filled.cpu_bus_peek(addr), 0xFF, "Filled(0xFF)");
2816        }
2817
2818        // Seeded is deterministic and differs from Zeroed.
2819        let mut seeded = Nes::from_rom_with_power_on_config(
2820            &rom,
2821            PowerOnConfig {
2822                ram: PowerOnRam::Seeded(42),
2823            },
2824        )
2825        .expect("boot");
2826        let dump: Vec<u8> = (0x0000u16..0x0100)
2827            .map(|x| seeded.cpu_bus_peek(x))
2828            .collect();
2829        assert!(dump.iter().any(|&b| b != 0), "Seeded: not all zero");
2830    }
2831
2832    #[test]
2833    fn ppu_revision_and_palette_default_byte_identical() {
2834        // v2.1.7 P5 — the PPU-revision + power-up-palette knobs default to the
2835        // byte-identical state, and toggling them is observable + power-cycle
2836        // durable.
2837        let rom = synth_nrom(16, 8);
2838        let mut nes = Nes::from_rom(&rom).expect("boot");
2839        assert_eq!(nes.ppu_revision(), PpuRevision::Rp2c02H);
2840        assert_eq!(nes.power_up_palette(), PaletteInit::Zeroed);
2841
2842        // Select the opt-in revision + Blargg palette; both must persist across a
2843        // power-cycle (the bus re-applies them after rebuilding the PPU).
2844        nes.set_ppu_revision(PpuRevision::Rp2c02G);
2845        nes.set_power_up_palette(PaletteInit::Blargg);
2846        nes.power_cycle();
2847        assert_eq!(
2848            nes.ppu_revision(),
2849            PpuRevision::Rp2c02G,
2850            "revision survives power-cycle"
2851        );
2852        assert_eq!(
2853            nes.power_up_palette(),
2854            PaletteInit::Blargg,
2855            "palette survives power-cycle"
2856        );
2857    }
2858
2859    #[test]
2860    fn nes_run_frame_completes_and_returns_framebuffer() {
2861        let rom = synth_nrom(16, 8);
2862        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
2863        let fb = nes.run_frame();
2864        assert_eq!(fb.len(), 256 * 240 * 4);
2865    }
2866
2867    #[test]
2868    fn nes_run_two_frames_distinct_completion_latches() {
2869        let rom = synth_nrom(16, 8);
2870        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
2871        nes.run_frame();
2872        let cycles_after_one = nes.cycle();
2873        nes.run_frame();
2874        let cycles_after_two = nes.cycle();
2875        assert!(cycles_after_two > cycles_after_one);
2876    }
2877
2878    #[test]
2879    fn nes_determinism_two_runs_match() {
2880        // T-24-002: same ROM + zero input + 60 frames -> bit-identical
2881        // framebuffer hash via FNV-1a.
2882        fn hash_fb(fb: &[u8]) -> u64 {
2883            let mut h: u64 = 0xCBF2_9CE4_8422_2325;
2884            for &b in fb {
2885                h ^= u64::from(b);
2886                h = h.wrapping_mul(0x0000_0100_0000_01B3);
2887            }
2888            h
2889        }
2890        let rom = synth_nrom(16, 8);
2891        let mut a = Nes::from_rom(&rom).unwrap();
2892        let mut b = Nes::from_rom(&rom).unwrap();
2893        let frames = 4;
2894        let mut hash_a = 0u64;
2895        let mut hash_b = 0u64;
2896        for _ in 0..frames {
2897            hash_a = hash_fb(a.run_frame());
2898            hash_b = hash_fb(b.run_frame());
2899        }
2900        assert_eq!(
2901            hash_a, hash_b,
2902            "two runs must produce identical framebuffer"
2903        );
2904    }
2905
2906    fn fnv_hash(bytes: &[u8]) -> u64 {
2907        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
2908        for &b in bytes {
2909            h ^= u64::from(b);
2910            h = h.wrapping_mul(0x0000_0100_0000_01B3);
2911        }
2912        h
2913    }
2914
2915    #[test]
2916    fn snapshot_round_trip_preserves_framebuffer_and_cycle() {
2917        let rom = synth_nrom(16, 8);
2918        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
2919        for _ in 0..4 {
2920            nes.run_frame();
2921        }
2922        let cycle = nes.cycle();
2923        let fb_hash_before = fnv_hash(nes.framebuffer());
2924        let blob = nes.snapshot();
2925
2926        // Drift the emulator forward 4 more frames so it looks different.
2927        for _ in 0..4 {
2928            nes.run_frame();
2929        }
2930        assert_ne!(nes.cycle(), cycle, "drift must move us off the snapshot");
2931
2932        nes.restore(&blob).expect("restore");
2933        assert_eq!(nes.cycle(), cycle);
2934        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash_before);
2935    }
2936
2937    #[test]
2938    fn snapshot_is_deterministic_across_two_runs() {
2939        let rom = synth_nrom(16, 8);
2940        let mut a = Nes::from_rom(&rom).unwrap();
2941        let mut b = Nes::from_rom(&rom).unwrap();
2942        for _ in 0..3 {
2943            a.run_frame();
2944            b.run_frame();
2945        }
2946        assert_eq!(a.snapshot(), b.snapshot());
2947    }
2948
2949    #[test]
2950    fn snapshot_header_carries_rom_hash_tag() {
2951        let rom = synth_nrom(16, 8);
2952        let nes = Nes::from_rom(&rom).unwrap();
2953        let blob = nes.snapshot();
2954        let (h, _off) = save_state::parse_header(&blob).unwrap();
2955        assert_eq!(h.rom_hash_tag, nes.rom_hash_tag());
2956    }
2957
2958    #[test]
2959    fn restore_rejects_pre_v3_cpu_section_version() {
2960        // ADR 0028 (v2.0.0 rc.1): a slot file whose CPU section predates the
2961        // one-clock promote (schema version < CPU_SNAPSHOT_VERSION) must be
2962        // cleanly rejected via SnapshotError::VersionMismatch, not silently
2963        // accepted or upconverted. Simulate an old slot by taking a
2964        // freshly-emitted (current-version) snapshot and patching only the
2965        // CPU section's version byte down to a stale value -- everything
2966        // else (the body bytes, every other section) is untouched, so this
2967        // isolates the version-gate behavior from any layout difference.
2968        let rom = synth_nrom(16, 8);
2969        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
2970        for _ in 0..3 {
2971            nes.run_frame();
2972        }
2973        let current = nes.snapshot();
2974        let (_h, body_off) = save_state::parse_header(&current).unwrap();
2975        let mut stale = current[..body_off].to_vec();
2976        for s in save_state::SectionIter::new(&current[body_off..]) {
2977            let s = s.unwrap();
2978            let version = if s.tag == save_state::tag::CPU {
2979                assert_eq!(
2980                    s.version,
2981                    rustynes_cpu::CPU_SNAPSHOT_VERSION,
2982                    "fixture assumption: current build writes the current CPU version"
2983                );
2984                s.version - 1
2985            } else {
2986                s.version
2987            };
2988            save_state::write_section(&mut stale, s.tag, version, s.body);
2989        }
2990        let err = nes.restore(&stale).unwrap_err();
2991        assert!(
2992            matches!(
2993                err,
2994                SnapshotError::VersionMismatch { ref tag, .. } if tag == "CPU "
2995            ),
2996            "expected a CPU-tagged VersionMismatch, got {err:?}"
2997        );
2998    }
2999
3000    #[test]
3001    fn rewind_step_back_restores_prior_frame() {
3002        let rom = synth_nrom(16, 8);
3003        let mut nes = Nes::from_rom(&rom).unwrap();
3004        nes.enable_rewind_with(2 * 1024 * 1024, 1);
3005        for _ in 0..6 {
3006            nes.run_frame();
3007        }
3008        let cycle_at_6 = nes.cycle();
3009        nes.run_frame();
3010        nes.run_frame();
3011        nes.run_frame();
3012        // 3 entries on the ring (frames 6..=8 captured at the END of each
3013        // run_frame — frame 5 was captured in the loop above).
3014        assert!(nes.rewind_step_back(), "first step back");
3015        assert!(nes.rewind_step_back(), "second step back");
3016        assert!(nes.rewind_step_back(), "third step back");
3017        // We've rewound past the 3 extra frames; cycle should equal the
3018        // state we captured at the end of frame 6 (i.e. frame 5's snap).
3019        assert_ne!(nes.cycle(), cycle_at_6, "captured frame 5, not frame 6");
3020    }
3021
3022    #[test]
3023    fn rewind_disabled_no_op() {
3024        let rom = synth_nrom(16, 8);
3025        let mut nes = Nes::from_rom(&rom).unwrap();
3026        nes.run_frame();
3027        assert!(!nes.rewind_step_back());
3028        assert_eq!(nes.rewind_len(), 0);
3029    }
3030
3031    #[cfg(feature = "debug-hooks")]
3032    #[test]
3033    fn breakpoint_stops_run_frame_at_pc() {
3034        let rom = synth_nrom(16, 8);
3035        // A PC the CPU provably reaches: the PC after the first 3 executed
3036        // instructions (a fresh run replays the same deterministic sequence).
3037        let mut probe = Nes::from_rom(&rom).expect("parse");
3038        for _ in 0..3 {
3039            probe.step_instruction();
3040        }
3041        let target = probe.cpu.pc;
3042
3043        let mut nes = Nes::from_rom(&rom).expect("parse");
3044        // The PPU's frame-complete latch is set at power-on, so the first
3045        // `run_frame` returns immediately without iterating; warm past it.
3046        let _ = nes.run_frame();
3047        nes.add_breakpoint(target);
3048        nes.add_breakpoint(target); // idempotent
3049        assert_eq!(nes.breakpoints(), &[target]);
3050
3051        let _ = nes.run_frame();
3052        assert_eq!(
3053            nes.take_break_hit(),
3054            Some(target),
3055            "stops at the breakpoint PC"
3056        );
3057        assert_eq!(nes.take_break_hit(), None, "hit cleared on read");
3058
3059        // Resuming ("continue") steps past the stopped PC instead of
3060        // re-breaking in place, then hits the same PC again next loop.
3061        let _ = nes.run_frame();
3062        assert_eq!(
3063            nes.take_break_hit(),
3064            Some(target),
3065            "resume steps past, then re-hits on the next pass"
3066        );
3067
3068        // Disarmed breakpoints don't fire (the frame runs to completion).
3069        nes.set_breakpoints_enabled(false);
3070        let _ = nes.run_frame();
3071        assert_eq!(nes.take_break_hit(), None, "disarmed: no break");
3072
3073        // Removal empties the list.
3074        nes.remove_breakpoint(target);
3075        assert!(nes.breakpoints().is_empty());
3076
3077        // Regression (gemini #41): a breakpoint sitting at the frame's STARTING
3078        // PC must fire immediately — the old `first_iter` skip missed it.
3079        let mut nes2 = Nes::from_rom(&rom).expect("parse");
3080        let _ = nes2.run_frame(); // warm past the power-on frame-complete latch
3081        let start_pc = nes2.cpu.pc;
3082        nes2.add_breakpoint(start_pc);
3083        let _ = nes2.run_frame();
3084        assert_eq!(
3085            nes2.take_break_hit(),
3086            Some(start_pc),
3087            "breaks immediately when starting already on a breakpoint"
3088        );
3089    }
3090
3091    #[cfg(feature = "debug-hooks")]
3092    #[test]
3093    fn trace_logger_records_while_enabled() {
3094        let rom = synth_nrom(16, 8);
3095        let mut nes = Nes::from_rom(&rom).expect("parse");
3096        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
3097        assert_eq!(nes.trace_len(), 0, "off by default");
3098        nes.set_trace_enabled(true);
3099        let _ = nes.run_frame();
3100        assert!(nes.trace_len() > 0, "records while enabled");
3101        // Records carry the executed PCs (the synth ROM spins at $C000).
3102        let recs = nes.trace_records();
3103        assert!(recs.iter().any(|r| r.pc == 0xC000), "captured the loop PC");
3104        // The tail copy is bounded.
3105        assert!(nes.trace_tail_vec(4).len() <= 4);
3106        // Disabling stops growth; clearing empties.
3107        nes.set_trace_enabled(false);
3108        let n = nes.trace_len();
3109        let _ = nes.run_frame();
3110        assert_eq!(nes.trace_len(), n, "no new records when disabled");
3111        nes.clear_trace();
3112        assert_eq!(nes.trace_len(), 0);
3113    }
3114
3115    #[cfg(feature = "debug-hooks")]
3116    #[test]
3117    fn event_viewer_records_writes_with_ppu_position() {
3118        use crate::bus::EventKind;
3119        // A tiny NROM that loops `LDA #$00 ; STA $2000 ; JMP $C000`, so it
3120        // generates a PPU-register write ($2000) every iteration.
3121        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
3122        bytes[0..4].copy_from_slice(b"NES\x1A");
3123        bytes[4] = 1; // 1x16KB PRG
3124        bytes[5] = 1; // 1x8KB CHR (unused here)
3125        let prg = &mut bytes[16..16 + 16 * 1024];
3126        // $C000 maps to PRG offset 0.
3127        prg[0..8].copy_from_slice(&[0xA9, 0x00, 0x8D, 0x00, 0x20, 0x4C, 0x00, 0xC0]);
3128        let len = 16 * 1024;
3129        prg[len - 4] = 0x00; // reset vector lo
3130        prg[len - 3] = 0xC0; // reset vector hi -> $C000
3131        // CHR not appended (header says 1 bank but parse tolerates; use 0 banks).
3132        bytes[5] = 0;
3133
3134        let mut nes = Nes::from_rom(&bytes).expect("parse");
3135        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
3136        assert!(nes.events().is_empty(), "off by default");
3137        nes.set_event_logging(true);
3138        let _ = nes.run_frame();
3139        let evs = nes.events();
3140        assert!(!evs.is_empty(), "the STA $2000 loop produces writes");
3141        assert!(
3142            evs.iter().all(|e| e.kind == EventKind::PpuWrite
3143                && e.addr == 0x2000
3144                && e.dot <= 340
3145                && e.value == 0x00),
3146            "all events are $2000 PPU writes of $00 with a sane dot"
3147        );
3148        // Reset per frame: the count stays one-frame-bounded. The event log is
3149        // capped at `EVENT_CAP` (20_000, private to the bus module) — distinct
3150        // from the looser instruction-trace `TRACE_CAP` — so assert that bound.
3151        let _ = nes.run_frame();
3152        assert!(nes.events().len() <= 20_000, "bounded by EVENT_CAP");
3153        nes.set_event_logging(false);
3154        assert!(!nes.event_logging());
3155    }
3156
3157    #[cfg(feature = "debug-hooks")]
3158    #[test]
3159    fn event_viewer_records_ppu_reads() {
3160        use crate::bus::EventKind;
3161        // A tiny NROM that loops `LDA $2002 ; JMP $C000`, generating a PPU
3162        // STATUS read ($2002) every iteration (v1.5.0 Workstream A2 read tap).
3163        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
3164        bytes[0..4].copy_from_slice(b"NES\x1A");
3165        bytes[4] = 1; // 1x16KB PRG
3166        let prg = &mut bytes[16..16 + 16 * 1024];
3167        // $C000: LDA $2002 ; JMP $C000
3168        prg[0..6].copy_from_slice(&[0xAD, 0x02, 0x20, 0x4C, 0x00, 0xC0]);
3169        let len = 16 * 1024;
3170        prg[len - 4] = 0x00;
3171        prg[len - 3] = 0xC0;
3172
3173        let mut nes = Nes::from_rom(&bytes).expect("parse");
3174        let _ = nes.run_frame();
3175        nes.set_event_logging(true);
3176        let _ = nes.run_frame();
3177        let evs = nes.events();
3178        assert!(
3179            evs.iter()
3180                .any(|e| e.kind == EventKind::PpuRead && e.addr == 0x2002),
3181            "the LDA $2002 loop produces PPU reads"
3182        );
3183        assert!(
3184            evs.iter()
3185                .all(|e| e.kind.is_read() == (e.kind == EventKind::PpuRead)),
3186            "is_read is true only for PpuRead"
3187        );
3188        nes.set_event_logging(false);
3189    }
3190
3191    #[cfg(feature = "debug-hooks")]
3192    #[test]
3193    fn event_breakpoint_fires_on_armed_category_only() {
3194        use crate::EventBpKind;
3195        // The same `LDA #$00 ; STA $2000 ; JMP $C000` loop — it issues a PPU
3196        // write every iteration but never an APU write or interrupt service.
3197        let mut bytes = alloc::vec![0u8; 16 + 16 * 1024];
3198        bytes[0..4].copy_from_slice(b"NES\x1A");
3199        bytes[4] = 1;
3200        bytes[5] = 0;
3201        let prg = &mut bytes[16..16 + 16 * 1024];
3202        prg[0..8].copy_from_slice(&[0xA9, 0x00, 0x8D, 0x00, 0x20, 0x4C, 0x00, 0xC0]);
3203        let len = 16 * 1024;
3204        prg[len - 4] = 0x00;
3205        prg[len - 3] = 0xC0;
3206
3207        let mut nes = Nes::from_rom(&bytes).expect("parse");
3208        let _ = nes.run_frame(); // warm past the power-on frame-complete latch.
3209
3210        // Default: nothing armed, no hits.
3211        assert_eq!(nes.event_breakpoints(), 0, "disarmed by default");
3212        let _ = nes.run_frame();
3213        assert_eq!(nes.take_event_break_hit(), None, "no hit while disarmed");
3214
3215        // Arm an UNRELATED category (APU write): the $2000 loop never trips it.
3216        nes.set_event_breakpoints(EventBpKind::ApuWrite.bit());
3217        let _ = nes.run_frame();
3218        assert_eq!(
3219            nes.take_event_break_hit(),
3220            None,
3221            "wrong category does not fire"
3222        );
3223
3224        // Arm PPU write: the very next frame must latch a hit with sane context.
3225        nes.set_event_breakpoints(EventBpKind::PpuWrite.bit());
3226        let _ = nes.run_frame();
3227        let hit = nes.take_event_break_hit().expect("PPU write fires");
3228        assert_eq!(hit.kind, EventBpKind::PpuWrite);
3229        assert_eq!(hit.addr, 0x2000, "the STA $2000 target");
3230        assert!(hit.dot <= 340, "dot in range");
3231        assert!(
3232            hit.scanline >= -1 && hit.scanline <= 260,
3233            "scanline in range"
3234        );
3235        // Cleared on read + only one (first) hit recorded per frame.
3236        assert_eq!(nes.take_event_break_hit(), None, "cleared on read");
3237
3238        // Disarming all stops it firing again.
3239        nes.set_event_breakpoints(0);
3240        let _ = nes.run_frame();
3241        assert_eq!(nes.take_event_break_hit(), None, "disarmed: silent");
3242    }
3243
3244    #[cfg(feature = "debug-hooks")]
3245    #[test]
3246    fn event_bp_kind_mask_and_labels_are_distinct() {
3247        use crate::EventBpKind;
3248        let all = EventBpKind::all();
3249        // Every category has a distinct bit and a non-empty label.
3250        let mut seen = 0u16;
3251        for k in all {
3252            assert_eq!(seen & k.bit(), 0, "{} bit collides", k.label());
3253            seen |= k.bit();
3254            assert!(!k.label().is_empty());
3255        }
3256        assert_eq!(seen.count_ones() as usize, all.len(), "11 distinct bits");
3257    }
3258
3259    #[test]
3260    fn debug_snapshots_are_read_only() {
3261        // T-53-002+ -- inspection must not advance emulator state.
3262        let rom = synth_nrom(16, 8);
3263        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3264        for _ in 0..2 {
3265            nes.run_frame();
3266        }
3267        let cycle_before = nes.cycle();
3268        let _cpu = nes.cpu_snapshot();
3269        let _ppu = nes.ppu_snapshot();
3270        let _apu = nes.apu_snapshot();
3271        let _oam = nes.oam();
3272        let _pal = nes.palette_ram();
3273        let _mapper = nes.mapper_info();
3274        // cpu_bus_peek and pattern_table_rgba take &mut so we exercise them too.
3275        let _byte = nes.cpu_bus_peek(0xC000);
3276        let _byte = nes.ppu_bus_peek(0x2000);
3277        let pt = nes.pattern_table_rgba(0);
3278        assert_eq!(pt.len(), 128 * 128 * 4, "pattern table RGBA size");
3279        let nt = nes.nametable_rgba(0);
3280        assert_eq!(nt.len(), 256 * 240 * 4, "nametable RGBA size");
3281        assert_eq!(nes.cycle(), cycle_before, "inspection MUST NOT tick CPU");
3282    }
3283
3284    #[test]
3285    fn disassembler_round_trips_against_cpu_bus() {
3286        // Walk a small synthesized program through the disassembler.
3287        let rom = synth_nrom(16, 8);
3288        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3289        let pc = nes.cpu().pc;
3290        // Take a fixed-size byte window via the peek API first; disasm
3291        // wants a `Fn`, and our peek is `FnMut`.
3292        let mut buf = [0u8; 16];
3293        for (i, b) in buf.iter_mut().enumerate() {
3294            *b = nes.cpu_bus_peek(pc.wrapping_add(u16::try_from(i).unwrap_or(0)));
3295        }
3296        let lines = rustynes_cpu::disassemble_at(
3297            |a| {
3298                let off = a.wrapping_sub(pc) as usize;
3299                buf.get(off).copied().unwrap_or(0)
3300            },
3301            pc,
3302            4,
3303        );
3304        assert_eq!(lines.len(), 4);
3305        // First instruction is JMP $C000 (0x4C 0x00 0xC0).
3306        assert_eq!(lines[0].addr, pc);
3307        assert_eq!(lines[0].mnemonic, "JMP");
3308    }
3309
3310    #[test]
3311    fn rom_sha256_is_deterministic() {
3312        let rom = synth_nrom(16, 8);
3313        let nes_a = Nes::from_rom(&rom).unwrap();
3314        let nes_b = Nes::from_rom(&rom).unwrap();
3315        assert_eq!(nes_a.rom_sha256(), nes_b.rom_sha256());
3316        // Different ROM -> different hash.
3317        let mut other = synth_nrom(16, 8);
3318        other[0x10] = 0x99;
3319        let nes_c = Nes::from_rom(&other).unwrap();
3320        assert_ne!(nes_a.rom_sha256(), nes_c.rom_sha256());
3321    }
3322
3323    #[test]
3324    fn thumbnail_has_expected_dimensions() {
3325        let rom = synth_nrom(16, 8);
3326        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3327        nes.run_frame();
3328        let thumb = nes.thumbnail();
3329        assert_eq!(thumb.len(), save_state::THUMBNAIL_LEN);
3330        assert_eq!(
3331            save_state::THUMBNAIL_LEN,
3332            save_state::THUMBNAIL_WIDTH * save_state::THUMBNAIL_HEIGHT * 4
3333        );
3334    }
3335
3336    #[test]
3337    fn snapshot_includes_thumbnail_section_extractable() {
3338        let rom = synth_nrom(16, 8);
3339        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3340        for _ in 0..2 {
3341            nes.run_frame();
3342        }
3343        let blob = nes.snapshot();
3344        let extracted = Nes::extract_thumbnail(&blob).expect("blob is valid");
3345        let thumb = extracted.expect("snapshot must include THM section");
3346        assert_eq!(thumb.len(), save_state::THUMBNAIL_LEN);
3347        // Round-trip: thumbnail bytes must match the live framebuffer
3348        // downsample taken at the same cycle.
3349        assert_eq!(thumb, nes.thumbnail());
3350    }
3351
3352    #[test]
3353    fn snapshot_round_trip_still_works_with_thumbnail() {
3354        // ADR-0003 invariant: adding THM must not perturb deterministic
3355        // restore. Re-runs the snapshot_round_trip test with the new
3356        // thumbnail section present in the blob.
3357        let rom = synth_nrom(16, 8);
3358        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3359        for _ in 0..4 {
3360            nes.run_frame();
3361        }
3362        let cycle = nes.cycle();
3363        let fb_hash_before = fnv_hash(nes.framebuffer());
3364        let blob = nes.snapshot();
3365        for _ in 0..4 {
3366            nes.run_frame();
3367        }
3368        assert_ne!(nes.cycle(), cycle);
3369        nes.restore(&blob)
3370            .expect("restore must succeed with THM present");
3371        assert_eq!(nes.cycle(), cycle);
3372        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash_before);
3373    }
3374
3375    #[test]
3376    fn restore_accepts_v0_9_0_blob_without_thumbnail() {
3377        // ADR-0003 invariant: older slot files without a THM section must
3378        // still restore. Simulate a v0.9.0 blob by stripping the THM
3379        // section out of a freshly-emitted snapshot.
3380        let rom = synth_nrom(16, 8);
3381        let mut nes = Nes::from_rom(&rom).expect("parse + boot");
3382        for _ in 0..3 {
3383            nes.run_frame();
3384        }
3385        let cycle = nes.cycle();
3386        let fb_hash = fnv_hash(nes.framebuffer());
3387        let with_thumb = nes.snapshot();
3388
3389        // Reconstruct a blob without the THM section.
3390        let (_h, body_off) = save_state::parse_header(&with_thumb).unwrap();
3391        let mut without_thumb = with_thumb[..body_off].to_vec();
3392        for s in save_state::SectionIter::new(&with_thumb[body_off..]) {
3393            let s = s.unwrap();
3394            if s.tag == save_state::tag::THM {
3395                continue;
3396            }
3397            save_state::write_section(&mut without_thumb, s.tag, s.version, s.body);
3398        }
3399        assert!(without_thumb.len() < with_thumb.len());
3400        // Extract on the v0.9.0-shaped blob returns None for the thumbnail.
3401        let extracted = Nes::extract_thumbnail(&without_thumb).unwrap();
3402        assert!(extracted.is_none(), "v0.9.0 blob has no THM section");
3403
3404        // Drift then restore from the v0.9.0-shaped blob.
3405        for _ in 0..2 {
3406            nes.run_frame();
3407        }
3408        nes.restore(&without_thumb)
3409            .expect("v0.9.0 blob must restore");
3410        assert_eq!(nes.cycle(), cycle);
3411        assert_eq!(fnv_hash(nes.framebuffer()), fb_hash);
3412    }
3413
3414    /// Build a minimal NSF (3 songs) whose `init` enables all APU channels and
3415    /// programs a steady pulse-1 tone, and whose `play` is a bare `RTS`. Loaded
3416    /// at $8000; init=$8000, play=$800C.
3417    fn synth_tone_nsf() -> Vec<u8> {
3418        let mut f = vec![0u8; 0x80];
3419        f[0..5].copy_from_slice(b"NESM\x1A");
3420        f[0x05] = 1; // version
3421        f[0x06] = 3; // total songs
3422        f[0x07] = 1; // starting song
3423        f[0x08] = 0x00;
3424        f[0x09] = 0x80; // load $8000
3425        f[0x0A] = 0x00;
3426        f[0x0B] = 0x80; // init $8000
3427        f[0x0C] = 0x0C;
3428        f[0x0D] = 0x80; // play $800C
3429        let program: &[u8] = &[
3430            // init ($8000): enable channels + a constant-volume pulse-1 tone.
3431            0xA9, 0x0F, 0x8D, 0x15, 0x40, // LDA #$0F; STA $4015
3432            0xA9, 0xBF, 0x8D, 0x00, 0x40, // LDA #$BF; STA $4000 (duty/const vol)
3433            0x60, // RTS
3434            0xA0, // padding so play lands at $800C
3435            // play ($800C):
3436            0x60, // RTS
3437        ];
3438        f.extend_from_slice(program);
3439        f
3440    }
3441
3442    #[test]
3443    fn nsf_constructs_runs_and_selects_tracks() {
3444        let mut nes = Nes::from_nsf(&synth_tone_nsf()).expect("valid nsf builds");
3445        assert_eq!(nes.nsf_song_count(), 3);
3446        assert_eq!(nes.nsf_current_song(), 0);
3447
3448        // Run several frames: the driver's reset vector runs `init` (enabling
3449        // the APU + pulse-1), then vblank NMI calls `play` each frame. Audio
3450        // must be produced and the run must not panic.
3451        let mut produced = 0usize;
3452        for _ in 0..8 {
3453            nes.run_frame();
3454            produced += nes.drain_audio().len();
3455        }
3456        assert!(produced > 0, "NSF playback must produce audio samples");
3457
3458        // Track select clamps + restarts on the new song.
3459        nes.nsf_set_song(2);
3460        assert_eq!(nes.nsf_current_song(), 2);
3461        nes.run_frame();
3462        nes.nsf_set_song(99);
3463        assert_eq!(nes.nsf_current_song(), 2, "clamped to last song");
3464    }
3465
3466    /// A minimal non-60-Hz NSF whose `play` routine increments zero-page `$00`,
3467    /// so a test can count how many times the cycle-timer IRQ drove `play`.
3468    fn synth_counting_nsf_50hz() -> Vec<u8> {
3469        let mut f = vec![0u8; 0x80];
3470        f[0..5].copy_from_slice(b"NESM\x1A");
3471        f[0x05] = 1; // version
3472        f[0x06] = 1; // 1 song
3473        f[0x07] = 1; // starting song
3474        f[0x08] = 0x00;
3475        f[0x09] = 0x80; // load $8000
3476        f[0x0A] = 0x00;
3477        f[0x0B] = 0x80; // init $8000
3478        f[0x0C] = 0x06;
3479        f[0x0D] = 0x80; // play $8006
3480        // NTSC play-speed divider $6E-$6F = 20000 µs (~50 Hz) — a non-standard
3481        // rate that selects the cycle-timer IRQ driver instead of vblank-NMI.
3482        f[0x6E] = 0x20;
3483        f[0x6F] = 0x4E; // 0x4E20 = 20000
3484        let program: &[u8] = &[
3485            // init ($8000): enable APU channels, RTS. (6 bytes)
3486            0xA9, 0x0F, 0x8D, 0x15, 0x40, 0x60, // play ($8006): INC $00; RTS
3487            0xE6, 0x00, 0x60,
3488        ];
3489        f.extend_from_slice(program);
3490        f
3491    }
3492
3493    #[test]
3494    fn nsf_nonstandard_rate_drives_play_via_timer_irq() {
3495        let mut nes = Nes::from_nsf(&synth_counting_nsf_50hz()).expect("valid nsf");
3496        // 12 NTSC frames ≈ 0.2 s. A ~50 Hz play-timer IRQ must fire `play`
3497        // (INC $00) several times — proving the cycle-timer IRQ path works
3498        // end-to-end through `run_frame` — but FEWER than the 12 frames, since
3499        // 50 Hz is slower than the 60 Hz once-per-vblank rate.
3500        for _ in 0..12 {
3501            nes.run_frame();
3502        }
3503        let calls = nes.cpu_bus_peek(0x0000);
3504        assert!(
3505            calls > 0,
3506            "timer IRQ must drive `play` at the non-standard rate"
3507        );
3508        assert!(
3509            calls < 12,
3510            "50 Hz must call `play` fewer times than 60 Hz frames (got {calls})"
3511        );
3512    }
3513
3514    #[test]
3515    fn nsf_song_apis_are_inert_on_a_cartridge() {
3516        let mut nes = Nes::from_rom(&synth_nrom(16, 8)).expect("nrom builds");
3517        assert_eq!(nes.nsf_song_count(), 0);
3518        assert_eq!(nes.nsf_current_song(), 0);
3519        nes.nsf_set_song(1); // no-op, must not panic or reset spuriously
3520        assert_eq!(nes.nsf_current_song(), 0);
3521    }
3522}