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