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