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