rustysnes_core/scheduler.rs
1//! The master-clock lockstep scheduler — the run loop that owns the CPU + Bus.
2//!
3//! Timing master: the 21.477 MHz SNES master crystal. The 65C816 drives the clock: each of its
4//! bus accesses advances the master clock by the region access speed (6/8/12), and that advance
5//! steps the PPU dot clock + SPC accumulator in lockstep (inside [`crate::Bus`]). This is
6//! LOCKSTEP, not catch-up — mid-instruction timing-master events (an HV-IRQ at a precise dot, a
7//! mid-scanline register write) land correctly without per-quirk patches (`docs/adr/0001`).
8//!
9//! The scheduler's job on top of the Bus is the *frame structure*: reset the CPU from the
10//! cart's reset vector, step instructions until the PPU signals end-of-frame, and fire the
11//! per-line HDMA + the per-frame HDMA setup at the right scanline phases.
12
13use alloc::vec::Vec;
14
15use rustysnes_cpu::{Cpu, Regs};
16use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
17
18use crate::bus::Bus;
19use crate::sa1_bus::Sa1Bus;
20
21/// The save-state format's major version (`docs/adr/0006-save-state-format.md`). Bump this any
22/// time a section's on-disk layout changes in a way an older reader can't skip past; the reader
23/// (this crate's [`System::load_state`]) rejects any `found > FORMAT_VERSION` rather than
24/// silently misinterpreting a newer layout.
25///
26/// `2` (`v0.7.0 "Resolution"`): `rustysnes-ppu`'s `PPU0` section grew — the framebuffer's backing
27/// storage is now always allocated at hi-res capacity (512×239, up from 256×239) to support true
28/// hi-res (Modes 5/6) output, and a new `frame_hires` bool was added — a real byte-layout change
29/// to an existing section (`docs/ppu.md` §Hi-res (Modes 5/6) color-math precision). Note this
30/// bump only guards against loading a *newer*-than-supported blob (`load_state` rejects `found >
31/// FORMAT_VERSION`); it does not add graceful old-format loading — a genuinely older blob loaded
32/// by this code fails with a real parse/truncation error (proven by
33/// `crates/rustysnes-test-harness/tests/save_state_backward_compat.rs`'s `tests/golden/
34/// savestate-v1-gilyon.bin` fixture), not silent misinterpretation. See
35/// `docs/adr/0006-save-state-format.md`'s bump log for the full record.
36///
37/// `3` (`v0.9.0`, Phase 7 niche peripherals): `crate::bus`'s `BUS0` section grew — a new WRIO
38/// (`$4201`/`$4213`) `pio` byte plus each controller port's [`crate::controller::PortState`]
39/// (device selection + Mouse/Super Scope/Super Multitap runtime state). Same guarantee as the `2`
40/// bump above: a `FORMAT_VERSION < 3` blob fails loudly (a `BUS0` section-length mismatch), not
41/// silently.
42///
43/// `5` (Tier-1 T-CA-01/03): `crate::bus`'s `BUS0` section grew again — the in-flight automatic
44/// joypad read's start snapshot (`joypad_auto_pending`) and busy deadline (`auto_joypad_busy_until`),
45/// so a save taken during the ~4224-clock auto-read window restores identical machine state. Same
46/// old-blob-fails-loudly guarantee.
47///
48/// `6` (Tier-1 T-CA-02): `crate::bus`'s `BUS0` section grew by two bytes — the RDNMI/TIMEUP hold
49/// flags (`rdnmi_hold`/`irq_hold`), so a save taken during the four-master-clock window after a
50/// `VBlank`/IRQ edge (when a `$4210`/`$4211` read returns the flag without clearing it) restores
51/// identical machine state. Same old-blob-fails-loudly guarantee.
52///
53/// `7` (T-CA-10 Phase 4b): `rustysnes_ppu`'s `PPU0` section grew by one byte — the OAM
54/// sprite-evaluation seed (`pd_oam_eval_seed`), from which the in-render `$2104` redirect derives
55/// the evaluator's OAM index. It is captured at line start and diverges from `OAMADDR` after
56/// redirected active-display writes (with priority rotation), so it cannot be re-derived on load and
57/// must persist for a mid-line save to restore identical machine state (mirrors MesenCE serializing
58/// `_oamEvaluationIndex`). Same old-blob-fails-loudly guarantee.
59///
60/// `8` (T-CA-10 Phase 4b over-flag cursor): `PPU0` grew by one more byte — `pd_over_eval_seed`, the
61/// line-start priority-rotation seed the sprite over-flag (STAT77) timing evaluates from. Like
62/// `pd_oam_eval_seed` it is captured at line start and diverges from `OAMADDR` mid-line, so the
63/// over-flag set-dots (re-derived on load) must key off the persisted seed rather than the live
64/// address, or a mid-line save/load on a priority-rotated line would shift `$213E` timing. Same
65/// old-blob-fails-loudly guarantee.
66///
67/// `9` (auto-read start timing): `rustysnes_core`'s `BUS0` section grew by eight bytes —
68/// `auto_joypad_start_at`, the `clock.master` instant an armed automatic joypad read is scheduled to
69/// begin. Hardware starts the read ~dot 32.5-95.5 into the first vblank line, not at the vblank edge,
70/// so a save taken in that window must restore the pending start or the read never begins on load
71/// (`$4212` bit 0 and `$4218-$421F` would desync). Same old-blob-fails-loudly guarantee.
72///
73/// `10` (NEC DSP pin-exact clock): the `NDSP` coprocessor section grew by eight bytes — `dsp_accum`,
74/// the µPD77C25/µPD96050's master-clock fractional-accumulator phase. The DSP now free-runs on its
75/// own 7.6/11 MHz divisor (stepped every master tick via `coprocessor_tick`) instead of catching up
76/// synchronously on each host DR access, so its sub-master-clock position must persist or a
77/// mid-computation save would restore the wrong RQM-handshake timing. Same old-blob-fails-loudly
78/// guarantee (only carts carrying a NEC DSP — DSP-1/2/4/ST010 — have an `NDSP` section at all).
79const FORMAT_VERSION: u16 = 10;
80/// The save-state envelope's leading magic bytes — identifies the blob as a RustySNES save-state
81/// before anything else is trusted.
82const MAGIC: &[u8; 4] = b"RSNS";
83
84/// A generous instruction budget per frame so a wedged ROM can't spin forever in `run_frame`.
85const MAX_STEPS_PER_FRAME: u64 = 2_000_000;
86
87/// The SA-1 65C816 runs at ~10.74 MHz = master clock / 2, so each SA-1 CPU cycle is **2 master
88/// clocks**. The scheduler advances the SA-1 in a deterministic catch-up bounded by the master
89/// clock that the (untouched) main CPU has already advanced.
90const SA1_MASTER_PER_CYCLE: u64 = 2;
91
92/// Safety cap on SA-1 instructions executed in a single catch-up call (a wedged SA-1 program can't
93/// spin forever); far above any real per-step budget.
94const MAX_SA1_STEPS_PER_CALL: u32 = 200_000;
95
96/// Owns the run loop. Determinism contract: same seed + ROM + input => bit-identical AV.
97#[derive(Debug)]
98pub struct System {
99 /// The Bus — owns everything mutable (PPU/APU/cart/WRAM/controllers/DMA + the master clock).
100 pub bus: Bus,
101 /// The 65C816 main CPU. It borrows `&mut bus` for each [`Cpu::step`].
102 pub cpu: Cpu,
103 /// Per-power-on phase alignment, from the determinism seed (never OS RNG).
104 seed: u64,
105 /// Whether [`System::reset`] has loaded the reset vector for the installed cart.
106 booted: bool,
107 /// The PPU scanline observed on the previous step (to detect line boundaries for HDMA).
108 last_line: u16,
109 /// The second 65C816 (the SA-1's CPU), present only when an SA-1 cart is installed. Stepped in
110 /// deterministic catch-up against the main CPU's master-clock advance (`docs/scheduler.md`
111 /// §SA-1). `None` for every non-SA-1 cart, so the main CPU's behaviour/timing is unchanged.
112 sa1_cpu: Option<Cpu>,
113 /// Master-clock value last accounted to the SA-1 catch-up (delta = now − this).
114 sa1_last_master: u64,
115 /// Sub-cycle master-clock credit carried between SA-1 catch-up calls.
116 sa1_credit: u64,
117 /// `PBR:PC` of the instruction `trace_step` recorded, carried to `trace_after_step` so the
118 /// control-flow classifier can name where the transfer came *from* (`v1.25.0`, T-FP-C1).
119 /// Debugger-only state; never in a save state.
120 #[cfg(feature = "debug-hooks")]
121 pending_trace_pc: u32,
122 /// That instruction's opcode, likewise carried across the step.
123 #[cfg(feature = "debug-hooks")]
124 pending_trace_opcode: u8,
125 /// `Cpu::interrupts_taken` as of `trace_step`. A step that vectored an NMI/IRQ never fetched an
126 /// opcode at all, so the classifier must read this rather than `pending_trace_opcode` — which
127 /// on such a step still holds the *previous* instruction's byte.
128 #[cfg(feature = "debug-hooks")]
129 pending_trace_interrupts: u64,
130 /// Whether `trace_step` actually armed the fields above this step. Without it, arming tracing
131 /// between the two hooks would classify against whatever the last traced step left behind.
132 #[cfg(feature = "debug-hooks")]
133 pending_trace_armed: bool,
134}
135
136impl System {
137 /// Power on with a determinism seed.
138 #[must_use]
139 pub fn new(seed: u64) -> Self {
140 Self {
141 bus: Bus::default(),
142 cpu: Cpu::new(),
143 seed,
144 booted: false,
145 last_line: 0,
146 sa1_cpu: None,
147 sa1_last_master: 0,
148 sa1_credit: 0,
149 #[cfg(feature = "debug-hooks")]
150 pending_trace_pc: 0,
151 #[cfg(feature = "debug-hooks")]
152 pending_trace_opcode: 0,
153 #[cfg(feature = "debug-hooks")]
154 pending_trace_interrupts: 0,
155 #[cfg(feature = "debug-hooks")]
156 pending_trace_armed: false,
157 }
158 }
159
160 /// Reset the CPU from the cart's emulation reset vector (`$00FFFC`). Safe to call with no
161 /// cart (the CPU reads open bus and parks); the boot flag tracks readiness. Auto-detects
162 /// NTSC vs PAL from the installed cart's header (`Bus::sync_region_from_cart`) before
163 /// resetting the CPU, so a PAL cart boots at the PAL line count/timing without the caller
164 /// (frontend or test) having to know or guess the region up front.
165 pub fn reset(&mut self) {
166 self.bus.sync_region_from_cart();
167 self.cpu.reset(&mut self.bus);
168 self.booted = self.bus.cart.is_some();
169 self.last_line = self.bus.ppu.scanline();
170 // Instantiate the SA-1's second CPU iff the installed cart carries one. It stays held in
171 // reset (the SA-1 board powers up with RESB asserted) until the main CPU clears RESB, at
172 // which point `run_sa1` resets it from the SA-1 reset vector (CRV).
173 self.sa1_cpu = self
174 .bus
175 .cart
176 .as_ref()
177 .filter(|c| c.board.has_second_cpu())
178 .map(|_| Cpu::new());
179 self.sa1_last_master = self.bus.clock.master;
180 self.sa1_credit = 0;
181 }
182
183 /// Advance the SA-1's second CPU to catch up with the master clock the main CPU has elapsed
184 /// since the last call. Deterministic and bounded entirely by `bus.clock.master` (which is a
185 /// pure function of the untouched main CPU), so installing the second CPU never perturbs the
186 /// main CPU's behaviour or the existing scheduler timing.
187 fn run_sa1(&mut self) {
188 let Some(mut cpu) = self.sa1_cpu.take() else {
189 return;
190 };
191 let now = self.bus.clock.master;
192 let delta = now.wrapping_sub(self.sa1_last_master);
193 self.sa1_last_master = now;
194 let mut credit = self.sa1_credit + delta;
195
196 if let Some(cart) = self.bus.cart.as_mut() {
197 let board = cart.board.as_mut();
198 if board.has_second_cpu() {
199 if board.second_cpu_take_reset() {
200 let mut adapter = Sa1Bus { board: &mut *board };
201 cpu.reset(&mut adapter);
202 }
203 let mut guard = 0u32;
204 while credit >= SA1_MASTER_PER_CYCLE && guard < MAX_SA1_STEPS_PER_CALL {
205 guard += 1;
206 if board.second_cpu_running() {
207 let cyc = {
208 let mut adapter = Sa1Bus { board: &mut *board };
209 cpu.step(&mut adapter)
210 };
211 // SA-1 cycles → master clocks (×2). `cyc` is a single instruction's count,
212 // so this never overflows a u32.
213 let clocks = cyc.max(1).saturating_mul(2);
214 board.second_cpu_tick(clocks);
215 credit = credit.saturating_sub(u64::from(clocks));
216 } else {
217 // Held in reset / asleep: drain the budget into the timer in one go (keeps
218 // the H/V counters advancing) and stop stepping the CPU.
219 let drain = credit & !1;
220 board.second_cpu_tick(u32::try_from(drain).unwrap_or(u32::MAX) & !1);
221 credit &= 1;
222 }
223 }
224 } else {
225 credit = 0;
226 }
227 } else {
228 credit = 0;
229 }
230
231 self.sa1_credit = credit;
232 self.sa1_cpu = Some(cpu);
233 }
234
235 /// Run one full video frame: step the CPU until the PPU's frame-count advances, firing the
236 /// per-frame HDMA setup at the top of the frame and the per-line HDMA at each visible-line
237 /// boundary.
238 pub fn run_frame(&mut self) {
239 if !self.booted {
240 self.reset();
241 }
242 if self.bus.cart.is_none() {
243 return; // nothing to run; the frontend shows a blank frame.
244 }
245
246 let start_frame = self.bus.ppu.frame_count();
247 let mut steps = 0u64;
248
249 // HDMA per-frame init + per-line transfers are now driven clock-accurately from
250 // `Bus::advance_master` (at V=0 and each visible line), so they stay correct even when a
251 // framebuffer DMA spans the frame boundary. The scheduler no longer sequences HDMA.
252
253 while self.bus.ppu.frame_count() == start_frame && steps < MAX_STEPS_PER_FRAME {
254 #[cfg(feature = "debug-hooks")]
255 self.bus
256 .set_debug_pc((u32::from(self.cpu.regs.pbr) << 16) | u32::from(self.cpu.regs.pc));
257 #[cfg(feature = "debug-hooks")]
258 self.trace_step();
259 self.cpu.step(&mut self.bus);
260 #[cfg(feature = "debug-hooks")]
261 self.trace_after_step();
262 steps += 1;
263
264 // HDMA is now serviced clock-accurately inside `Bus::advance_master` (so it stays
265 // line-accurate even mid-GP-DMA); the scheduler no longer polls scanline boundaries.
266
267 // Catch the SA-1 up to the master clock (no-op when no SA-1 cart is installed).
268 if self.sa1_cpu.is_some() {
269 self.run_sa1();
270 }
271 }
272 }
273
274 /// Cumulative cycles the SA-1's second CPU has executed since power-on, or `None` when no SA-1
275 /// cart is installed. A non-zero value is the SA-1 liveness signal: the second 65C816 actually
276 /// fetched + executed out of the cart ROM (many SA-1 titles run their main logic on the SA-1).
277 #[must_use]
278 pub fn sa1_cycles(&self) -> Option<u64> {
279 self.sa1_cpu.as_ref().map(|c| c.cycles)
280 }
281
282 /// The SA-1 second CPU's architectural register file, or `None` when no SA-1 cart is
283 /// installed. For the debugger overlay's Cart panel (`docs/frontend.md` §Debugger overlay).
284 #[must_use]
285 pub fn sa1_regs(&self) -> Option<Regs> {
286 self.sa1_cpu.as_ref().map(|c| c.regs)
287 }
288
289 /// The determinism seed this `System` was constructed with (`Self::new`). A TAS movie's
290 /// power-on start point records this so a replay can verify the caller reconstructed the
291 /// System with the exact same seed before calling [`crate::movie::Movie::seek_to_start`] —
292 /// a different seed gives different power-on phase alignment, breaking bit-identical replay
293 /// even against the same ROM and input log (`docs/adr/0004`).
294 #[must_use]
295 pub const fn seed(&self) -> u64 {
296 self.seed
297 }
298
299 /// Step a single CPU instruction (drives the whole machine in lockstep via the Bus).
300 pub fn step_instruction(&mut self) {
301 if !self.booted {
302 self.reset();
303 }
304 #[cfg(feature = "debug-hooks")]
305 self.bus
306 .set_debug_pc((u32::from(self.cpu.regs.pbr) << 16) | u32::from(self.cpu.regs.pc));
307 #[cfg(feature = "debug-hooks")]
308 self.trace_step();
309 self.cpu.step(&mut self.bus);
310 #[cfg(feature = "debug-hooks")]
311 self.trace_after_step();
312 if self.sa1_cpu.is_some() {
313 self.run_sa1();
314 }
315 }
316
317 /// Record the instruction about to execute into the trace ring (`v1.25.0`, T-FP-C1).
318 ///
319 /// Pre-execution, because a trace is read to answer "what was the machine holding when it
320 /// decided to do that" — see [`crate::trace::TraceEntry`]. Costs one `bool` test when tracing
321 /// is disarmed, which is every build that has not explicitly turned it on.
322 #[cfg(feature = "debug-hooks")]
323 fn trace_step(&mut self) {
324 if !self.bus.trace().is_tracing() {
325 self.pending_trace_armed = false;
326 return;
327 }
328 let regs = self.cpu.regs;
329 let pbr_pc = (u32::from(regs.pbr) << 16) | u32::from(regs.pc);
330 // `Bus::peek` rather than a live read: a debugger fetch must not perturb the open-bus latch
331 // or trip a watchpoint, the same rule the disassembler already follows.
332 let opcode = self.bus.peek(pbr_pc);
333 self.bus.trace_mut().record_step(crate::trace::TraceEntry {
334 pbr_pc,
335 opcode,
336 a: regs.a,
337 x: regs.x,
338 y: regs.y,
339 sp: regs.s,
340 dp: regs.d,
341 p: regs.p.bits(),
342 db: regs.dbr,
343 emulation: regs.emulation,
344 });
345 self.pending_trace_pc = pbr_pc;
346 self.pending_trace_opcode = opcode;
347 self.pending_trace_interrupts = self.cpu.interrupts_taken;
348 self.pending_trace_armed = true;
349 }
350
351 /// Classify the instruction that just ran into a control-flow event (`v1.25.0`, T-FP-C1).
352 ///
353 /// Derived from the opcode plus the *actual* post-execution `PBR:PC` rather than from decoding
354 /// the operand: a `JSR` whose target is computed (`JSR (a,X)`) has no static destination, and a
355 /// conditional path would have to re-implement the CPU to know whether it was taken. Reading
356 /// where the CPU actually went cannot be wrong about either.
357 ///
358 /// An interrupt taken *between* instructions is caught **before** the opcode is consulted, and
359 /// has to be: on a step that vectored, the CPU fetched no opcode at all, so
360 /// `pending_trace_opcode` still holds the *previous* instruction's byte. Classifying from it
361 /// would not merely miss the interrupt — an NMI arriving right after a `JSR` would be recorded
362 /// as a second `Call`, with `to` pointing at the NMI vector's target. `Cpu::interrupts_taken`
363 /// is the unambiguous signal, which is why this is one classifier rather than separate call and
364 /// interrupt hooks.
365 #[cfg(feature = "debug-hooks")]
366 fn trace_after_step(&mut self) {
367 // `pending_trace_armed` guards the case where tracing was turned on between the two hooks:
368 // the fields below would then describe some earlier step entirely.
369 if !self.bus.trace().is_tracing() || !self.pending_trace_armed {
370 return;
371 }
372 if self.cpu.interrupts_taken != self.pending_trace_interrupts {
373 let kind = if self.cpu.last_interrupt_was_nmi {
374 crate::trace::EventKind::Nmi
375 } else {
376 crate::trace::EventKind::Irq
377 };
378 let to = (u32::from(self.cpu.regs.pbr) << 16) | u32::from(self.cpu.regs.pc);
379 self.bus
380 .trace_mut()
381 .record_event(kind, self.pending_trace_pc, to);
382 return;
383 }
384 let kind = match self.pending_trace_opcode {
385 // JSR a, JSL al, JSR (a,X)
386 0x20 | 0x22 | 0xFC => crate::trace::EventKind::Call,
387 // RTS, RTL
388 0x60 | 0x6B => crate::trace::EventKind::Return,
389 0x40 => crate::trace::EventKind::Rti,
390 0x00 => crate::trace::EventKind::Brk,
391 0x02 => crate::trace::EventKind::Cop,
392 _ => return,
393 };
394 let to = (u32::from(self.cpu.regs.pbr) << 16) | u32::from(self.cpu.regs.pc);
395 self.bus
396 .trace_mut()
397 .record_event(kind, self.pending_trace_pc, to);
398 }
399
400 /// Advance by one CPU instruction (kept for API compatibility with the old skeleton). The
401 /// real timebase advances through the CPU's bus accesses, not a bare master tick.
402 pub fn tick_one_master(&mut self) {
403 let _ = self.seed;
404 self.step_instruction();
405 }
406
407 /// Serialize the entire emulated machine — the main CPU, the whole [`Bus`] (PPU, APU, DMA,
408 /// WRAM, plus the cart's coprocessor state and battery SRAM if a cart is loaded), the
409 /// determinism seed, the boot/HDMA-line bookkeeping, and (if present) the SA-1 second CPU
410 /// plus its master-clock catch-up accounting — into a versioned binary blob
411 /// (`docs/adr/0006-save-state-format.md`). The blob leads with a 4-byte magic and a `u16`
412 /// format-version header that [`Self::load_state`] checks before trusting anything else. The
413 /// cart's ROM is never embedded (`docs/adr/0003`'s "never embed a ROM/firmware byte" posture,
414 /// already applied to every coprocessor's firmware) — restoring a cart-carrying save-state
415 /// requires the caller to have already loaded the SAME ROM onto the target `System` first.
416 #[must_use]
417 pub fn save_state(&self) -> Vec<u8> {
418 self.save_state_into(Vec::new())
419 }
420
421 /// [`Self::save_state`] into a caller-provided buffer, reusing its allocation (`v1.25.0`,
422 /// T-FP-F).
423 ///
424 /// Byte-for-byte identical output; the only difference is that the buffer's capacity survives.
425 /// Run-ahead snapshots every frame, so the per-frame allocation this avoids is the documented
426 /// blocker on making it default-on (`docs/frontend.md` §Run-ahead).
427 #[must_use]
428 pub fn save_state_into(&self, buf: Vec<u8>) -> Vec<u8> {
429 let mut w = SaveWriter::with_buffer(buf);
430 w.write_bytes(MAGIC);
431 w.write_u16(FORMAT_VERSION);
432 self.cpu.save_state(&mut w);
433 self.bus.save_state(&mut w);
434 w.section(*b"SYS0", |s| {
435 s.write_u64(self.seed);
436 s.write_bool(self.booted);
437 s.write_u16(self.last_line);
438 match &self.sa1_cpu {
439 Some(cpu) => {
440 s.write_bool(true);
441 cpu.save_state(s);
442 }
443 None => s.write_bool(false),
444 }
445 s.write_u64(self.sa1_last_master);
446 s.write_u64(self.sa1_credit);
447 });
448 w.into_bytes()
449 }
450
451 /// The inverse of [`Self::save_state`].
452 ///
453 /// # Errors
454 /// [`SaveStateError::BadMagic`] if `bytes` doesn't lead with the expected magic (not a
455 /// RustySNES save-state at all); [`SaveStateError::UnsupportedVersion`] if the format version
456 /// is newer than this build understands; [`SaveStateError`] on truncated/corrupt input or a
457 /// section with unconsumed trailing bytes; or [`SaveStateError::Invalid`] if the save-state's
458 /// SA-1-second-CPU presence, or (via [`Bus::load_state`]) cart presence/SRAM size, doesn't
459 /// match this `System`'s own installed state — restoring onto the wrong ROM/board
460 /// configuration is rejected rather than silently corrupting it.
461 pub fn load_state(&mut self, bytes: &[u8]) -> Result<(), SaveStateError> {
462 let mut r = SaveReader::new(bytes);
463 if r.read_bytes(4)? != MAGIC {
464 return Err(SaveStateError::BadMagic);
465 }
466 let version = r.read_u16()?;
467 if version > FORMAT_VERSION {
468 return Err(SaveStateError::UnsupportedVersion {
469 found: version,
470 max: FORMAT_VERSION,
471 });
472 }
473 self.cpu.load_state(&mut r)?;
474 self.bus.load_state(&mut r)?;
475 let mut s = r.expect_section(*b"SYS0")?;
476 self.seed = s.read_u64()?;
477 self.booted = s.read_bool()?;
478 self.last_line = s.read_u16()?;
479 let had_sa1 = s.read_bool()?;
480 match (&mut self.sa1_cpu, had_sa1) {
481 (Some(cpu), true) => cpu.load_state(&mut s)?,
482 (None, false) => {}
483 (Some(_), false) | (None, true) => {
484 return Err(SaveStateError::Invalid(alloc::string::String::from(
485 "save-state SA-1 second-CPU presence does not match this System's \
486 installed cart (load the same ROM before restoring)",
487 )));
488 }
489 }
490 self.sa1_last_master = s.read_u64()?;
491 self.sa1_credit = s.read_u64()?;
492 if s.remaining() != 0 {
493 return Err(SaveStateError::Invalid(alloc::format!(
494 "SYS0 section has {} trailing byte(s)",
495 s.remaining()
496 )));
497 }
498 // SYS0 is the envelope's last section; reject anything appended after it (a corrupted or
499 // concatenated blob), the same "no unconsumed trailing bytes" posture every nested
500 // section's own load_state already enforces on itself.
501 if r.remaining() != 0 {
502 return Err(SaveStateError::Invalid(alloc::format!(
503 "save-state has {} trailing byte(s) after the SYS0 section",
504 r.remaining()
505 )));
506 }
507 Ok(())
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use rustysnes_cart::Cart;
515 use rustysnes_ppu::Region as PpuRegion;
516
517 /// A minimal synthetic LoROM header (offset `$7FC0`) with a controllable region byte
518 /// (`$7FD9` for this LoROM offset — `field::REGION` in `rustysnes-cart`'s header module,
519 /// duplicated here rather than exported, since it's a small, stable, publicly-documented
520 /// header layout, `docs/cartridge-format.md`). `region == 0x00` selects Japan/NTSC; `0x02`
521 /// selects Europe/PAL (`Header::region_from_code`'s `0x02..=0x0C -> Pal` range).
522 fn synth_rom(region: u8) -> alloc::vec::Vec<u8> {
523 let mut rom = alloc::vec![0u8; 0x1_0000];
524 let h = 0x7FC0;
525 rom[h + 0x15] = 0x20; // MAP_MODE: slow LoROM
526 rom[h + 0x16] = 0x00; // CHIPSET: ROM only
527 rom[h + 0x18] = 0x00; // RAM_SIZE: none
528 rom[h + 0x19] = region; // REGION
529 let checksum: u16 = 0x1234;
530 let complement = !checksum;
531 rom[h + 0x1C..h + 0x1E].copy_from_slice(&complement.to_le_bytes());
532 rom[h + 0x1E..h + 0x20].copy_from_slice(&checksum.to_le_bytes());
533 rom[h + 0x3C..h + 0x3E].copy_from_slice(&0x8000u16.to_le_bytes()); // reset vector
534 rom
535 }
536
537 #[test]
538 fn ntsc_cart_auto_detects_ntsc_region_on_reset() {
539 let mut sys = System::new(0);
540 sys.bus.cart = Some(Cart::from_rom(&synth_rom(0x00)).expect("ntsc header"));
541 sys.reset();
542 assert_eq!(sys.bus.ppu.region(), PpuRegion::Ntsc);
543 assert_eq!(sys.bus.ppu.region().lines_per_frame(), 262);
544 }
545
546 #[test]
547 fn pal_cart_auto_detects_pal_region_on_reset() {
548 let mut sys = System::new(0);
549 sys.bus.cart = Some(Cart::from_rom(&synth_rom(0x02)).expect("pal header"));
550 sys.reset();
551 assert_eq!(sys.bus.ppu.region(), PpuRegion::Pal);
552 assert_eq!(sys.bus.ppu.region().lines_per_frame(), 312);
553
554 // End-to-end: booting and running one full frame actually completes at the PAL line
555 // count, not just the region flag being set (proves the auto-detected region reaches
556 // the PPU's real dot/scanline timeline, not merely a cosmetic label).
557 sys.run_frame();
558 assert_eq!(sys.bus.ppu.frame_count(), 1);
559 }
560
561 #[test]
562 fn no_cart_reset_does_not_touch_region() {
563 // sync_region_from_cart is a no-op with no cart installed; region stays at whatever the
564 // Bus was constructed with (System::new always builds NTSC by default).
565 let mut sys = System::new(0);
566 sys.reset();
567 assert_eq!(sys.bus.ppu.region(), PpuRegion::Ntsc);
568 }
569
570 #[test]
571 fn new_system_unbooted() {
572 let sys = System::new(0);
573 assert!(!sys.booted);
574 assert!(sys.bus.cart.is_none());
575 }
576
577 #[test]
578 fn run_frame_without_cart_is_noop() {
579 let mut sys = System::new(0);
580 sys.run_frame();
581 assert_eq!(sys.bus.ppu.frame_count(), 0);
582 }
583
584 #[test]
585 fn reset_without_cart_does_not_boot() {
586 let mut sys = System::new(0);
587 sys.reset();
588 assert!(!sys.booted);
589 }
590
591 /// A cartridge `/RESET` does **not** reset the PPU — its state survives (AccuracySNES `G1.06`,
592 /// an `[ERRATA]` row).
593 ///
594 /// **This assertion is on-cart impossible and that is why it lives here.** The reset line is
595 /// driven from outside the cartridge; a program cannot pull its own and then observe what
596 /// survived, because observing requires still running. The host can, so the host is where the
597 /// row is covered — see `dossier.rs::HOST_COVERED`, which records exactly that reasoning
598 /// rather than letting a host-tier cover look like an on-cart one.
599 ///
600 /// The mechanism is that `System::reset` resets the **CPU** and re-syncs the region, and
601 /// touches nothing in the PPU. That is easy to break by adding a `ppu.reset()` here for
602 /// tidiness, which is precisely what this test exists to catch.
603 #[test]
604 fn a_soft_reset_leaves_the_ppu_alone() {
605 let mut sys = System::new(0);
606 sys.bus.ppu.vram_mut()[0x1234] = 0xBEEF;
607 // Run the PPU well into a frame first. Resetting from the power-on position would leave
608 // the timeline at (0, 0) either way, so the test would pass whether or not the PPU is
609 // reset — the distinctive position is what makes the second assertion mean anything.
610 sys.bus.advance_master_for_test(80_000);
611 let line_before = sys.bus.ppu.scanline();
612 // Bounded on BOTH sides, not merely non-zero. The reset costs a handful of clocks, so a
613 // `line_before` near the end of a frame would let the line legitimately wrap to 0 and the
614 // assertion below would read that as a restart. Landing mid-frame is what makes it
615 // unambiguous, so the setup asserts it landed there.
616 assert!(
617 (10..200).contains(&line_before),
618 "setup did not leave the PPU mid-frame (line {line_before}); the assertion below \
619 cannot tell a frame wrap from a restart near a boundary"
620 );
621
622 sys.reset();
623
624 assert_eq!(
625 sys.bus.ppu.vram()[0x1234],
626 0xBEEF,
627 "a soft reset cleared VRAM — on hardware the PPU never sees the cartridge reset line, \
628 so a driver relying on its tiles surviving a Reset press would break"
629 );
630 let line_after = sys.bus.ppu.scanline();
631 assert!(
632 (line_before..=line_before + 1).contains(&line_after),
633 "a soft reset moved the PPU's timeline, from line {line_before} to {line_after}. The \
634 video clock free-runs across a cartridge reset: it may advance by the handful of \
635 clocks the vector fetch costs, but it must never restart"
636 );
637 }
638
639 #[test]
640 fn system_state_round_trips_without_a_cart() {
641 let mut sys = System::new(42);
642 sys.reset();
643 sys.cpu.regs.a = 0x1234;
644 sys.bus.clock.master = 999;
645
646 let bytes = sys.save_state();
647
648 let mut fresh = System::new(0);
649 fresh.load_state(&bytes).unwrap();
650
651 assert_eq!(fresh.cpu.regs.a, 0x1234);
652 assert_eq!(fresh.bus.clock.master, 999);
653 assert_eq!(fresh.seed, 42);
654 }
655
656 #[test]
657 fn bad_magic_is_rejected_not_panicked_on() {
658 let sys = System::new(0);
659 let mut bytes = sys.save_state();
660 bytes[0] = b'X';
661
662 let mut fresh = System::new(0);
663 assert!(matches!(
664 fresh.load_state(&bytes),
665 Err(SaveStateError::BadMagic)
666 ));
667 }
668
669 #[test]
670 fn newer_format_version_is_rejected_not_panicked_on() {
671 let sys = System::new(0);
672 let mut bytes = sys.save_state();
673 // The u16 format-version field immediately follows the 4-byte magic.
674 bytes[4..6].copy_from_slice(&(FORMAT_VERSION + 1).to_le_bytes());
675
676 let mut fresh = System::new(0);
677 assert!(matches!(
678 fresh.load_state(&bytes),
679 Err(SaveStateError::UnsupportedVersion { .. })
680 ));
681 }
682
683 /// A hardware NMI must be recorded as an `Nmi` control-flow event.
684 ///
685 /// This is not a redundant assertion about an enum: the classifier originally read only
686 /// `pending_trace_opcode`, and on a step that vectors, the CPU fetches **no** opcode — the
687 /// field still holds the PREVIOUS instruction's byte. So an NMI arriving after a `JSR` was
688 /// recorded as a second `Call` whose `to` pointed at the NMI vector's target, and an NMI after
689 /// an ordinary instruction was dropped entirely. The `to` assertion below is what pins the
690 /// difference: it must be the handler, not wherever the last instruction went.
691 #[cfg(feature = "debug-hooks")]
692 #[test]
693 fn a_hardware_nmi_is_recorded_as_an_nmi_event() {
694 const HANDLER: u16 = 0x9000;
695 let mut rom = synth_rom(0x00);
696 // `NOP` everywhere the CPU can land, so nothing else generates a control-flow event
697 // (a zero byte would be `BRK`, which does).
698 rom[0..0x7FC0].fill(0xEA);
699 // The program enables the VBlank NMI itself, rather than the test reaching into the
700 // Bus: `LDA #$80` / `STA $4200` (NMITIMEN bit 7). Neither is a control-flow instruction,
701 // so neither can produce an event of its own.
702 rom[0x0000..0x0005].copy_from_slice(&[0xA9, 0x80, 0x8D, 0x00, 0x42]);
703 // Emulation-mode NMI vector ($00:FFFA -> rom[$7FFA] under LoROM).
704 rom[0x7FFA..0x7FFC].copy_from_slice(&HANDLER.to_le_bytes());
705 rom[0x7FFC..0x7FFE].copy_from_slice(&0x8000u16.to_le_bytes()); // reset vector
706
707 let mut sys = System::new(0);
708 sys.bus.cart = Some(Cart::from_rom(&rom).expect("synth rom"));
709 sys.reset();
710 sys.bus.trace_mut().set_tracing(true);
711
712 // Run at most two frames' worth of instructions; VBlank arrives well inside that.
713 for _ in 0..200_000 {
714 sys.step_instruction();
715 if !sys.bus.trace().events().is_empty() {
716 break;
717 }
718 }
719
720 let events = sys.bus.trace().events();
721 let nmi = events
722 .iter()
723 .find(|e| e.kind == crate::trace::EventKind::Nmi)
724 .expect("an enabled VBlank NMI must produce an Nmi event");
725 assert_eq!(
726 nmi.to,
727 u32::from(HANDLER),
728 "`to` must be the NMI handler, not where the previous instruction went"
729 );
730 assert!(
731 !events
732 .iter()
733 .any(|e| e.kind == crate::trace::EventKind::Call),
734 "a NOP-only program plus one NMI must not record a Call"
735 );
736 }
737}