Skip to main content

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}