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.
42const FORMAT_VERSION: u16 = 3;
43/// The save-state envelope's leading magic bytes — identifies the blob as a RustySNES save-state
44/// before anything else is trusted.
45const MAGIC: &[u8; 4] = b"RSNS";
46
47/// A generous instruction budget per frame so a wedged ROM can't spin forever in `run_frame`.
48const MAX_STEPS_PER_FRAME: u64 = 2_000_000;
49
50/// The SA-1 65C816 runs at ~10.74 MHz = master clock / 2, so each SA-1 CPU cycle is **2 master
51/// clocks**. The scheduler advances the SA-1 in a deterministic catch-up bounded by the master
52/// clock that the (untouched) main CPU has already advanced.
53const SA1_MASTER_PER_CYCLE: u64 = 2;
54
55/// Safety cap on SA-1 instructions executed in a single catch-up call (a wedged SA-1 program can't
56/// spin forever); far above any real per-step budget.
57const MAX_SA1_STEPS_PER_CALL: u32 = 200_000;
58
59/// Owns the run loop. Determinism contract: same seed + ROM + input => bit-identical AV.
60#[derive(Debug)]
61pub struct System {
62    /// The Bus — owns everything mutable (PPU/APU/cart/WRAM/controllers/DMA + the master clock).
63    pub bus: Bus,
64    /// The 65C816 main CPU. It borrows `&mut bus` for each [`Cpu::step`].
65    pub cpu: Cpu,
66    /// Per-power-on phase alignment, from the determinism seed (never OS RNG).
67    seed: u64,
68    /// Whether [`System::reset`] has loaded the reset vector for the installed cart.
69    booted: bool,
70    /// The PPU scanline observed on the previous step (to detect line boundaries for HDMA).
71    last_line: u16,
72    /// The second 65C816 (the SA-1's CPU), present only when an SA-1 cart is installed. Stepped in
73    /// deterministic catch-up against the main CPU's master-clock advance (`docs/scheduler.md`
74    /// §SA-1). `None` for every non-SA-1 cart, so the main CPU's behaviour/timing is unchanged.
75    sa1_cpu: Option<Cpu>,
76    /// Master-clock value last accounted to the SA-1 catch-up (delta = now − this).
77    sa1_last_master: u64,
78    /// Sub-cycle master-clock credit carried between SA-1 catch-up calls.
79    sa1_credit: u64,
80}
81
82impl System {
83    /// Power on with a determinism seed.
84    #[must_use]
85    pub fn new(seed: u64) -> Self {
86        Self {
87            bus: Bus::default(),
88            cpu: Cpu::new(),
89            seed,
90            booted: false,
91            last_line: 0,
92            sa1_cpu: None,
93            sa1_last_master: 0,
94            sa1_credit: 0,
95        }
96    }
97
98    /// Reset the CPU from the cart's emulation reset vector (`$00FFFC`). Safe to call with no
99    /// cart (the CPU reads open bus and parks); the boot flag tracks readiness. Auto-detects
100    /// NTSC vs PAL from the installed cart's header (`Bus::sync_region_from_cart`) before
101    /// resetting the CPU, so a PAL cart boots at the PAL line count/timing without the caller
102    /// (frontend or test) having to know or guess the region up front.
103    pub fn reset(&mut self) {
104        self.bus.sync_region_from_cart();
105        self.cpu.reset(&mut self.bus);
106        self.booted = self.bus.cart.is_some();
107        self.last_line = self.bus.ppu.scanline();
108        // Instantiate the SA-1's second CPU iff the installed cart carries one. It stays held in
109        // reset (the SA-1 board powers up with RESB asserted) until the main CPU clears RESB, at
110        // which point `run_sa1` resets it from the SA-1 reset vector (CRV).
111        self.sa1_cpu = self
112            .bus
113            .cart
114            .as_ref()
115            .filter(|c| c.board.has_second_cpu())
116            .map(|_| Cpu::new());
117        self.sa1_last_master = self.bus.clock.master;
118        self.sa1_credit = 0;
119    }
120
121    /// Advance the SA-1's second CPU to catch up with the master clock the main CPU has elapsed
122    /// since the last call. Deterministic and bounded entirely by `bus.clock.master` (which is a
123    /// pure function of the untouched main CPU), so installing the second CPU never perturbs the
124    /// main CPU's behaviour or the existing scheduler timing.
125    fn run_sa1(&mut self) {
126        let Some(mut cpu) = self.sa1_cpu.take() else {
127            return;
128        };
129        let now = self.bus.clock.master;
130        let delta = now.wrapping_sub(self.sa1_last_master);
131        self.sa1_last_master = now;
132        let mut credit = self.sa1_credit + delta;
133
134        if let Some(cart) = self.bus.cart.as_mut() {
135            let board = cart.board.as_mut();
136            if board.has_second_cpu() {
137                if board.second_cpu_take_reset() {
138                    let mut adapter = Sa1Bus { board: &mut *board };
139                    cpu.reset(&mut adapter);
140                }
141                let mut guard = 0u32;
142                while credit >= SA1_MASTER_PER_CYCLE && guard < MAX_SA1_STEPS_PER_CALL {
143                    guard += 1;
144                    if board.second_cpu_running() {
145                        let cyc = {
146                            let mut adapter = Sa1Bus { board: &mut *board };
147                            cpu.step(&mut adapter)
148                        };
149                        // SA-1 cycles → master clocks (×2). `cyc` is a single instruction's count,
150                        // so this never overflows a u32.
151                        let clocks = cyc.max(1).saturating_mul(2);
152                        board.second_cpu_tick(clocks);
153                        credit = credit.saturating_sub(u64::from(clocks));
154                    } else {
155                        // Held in reset / asleep: drain the budget into the timer in one go (keeps
156                        // the H/V counters advancing) and stop stepping the CPU.
157                        let drain = credit & !1;
158                        board.second_cpu_tick(u32::try_from(drain).unwrap_or(u32::MAX) & !1);
159                        credit &= 1;
160                    }
161                }
162            } else {
163                credit = 0;
164            }
165        } else {
166            credit = 0;
167        }
168
169        self.sa1_credit = credit;
170        self.sa1_cpu = Some(cpu);
171    }
172
173    /// Run one full video frame: step the CPU until the PPU's frame-count advances, firing the
174    /// per-frame HDMA setup at the top of the frame and the per-line HDMA at each visible-line
175    /// boundary.
176    pub fn run_frame(&mut self) {
177        if !self.booted {
178            self.reset();
179        }
180        if self.bus.cart.is_none() {
181            return; // nothing to run; the frontend shows a blank frame.
182        }
183
184        let start_frame = self.bus.ppu.frame_count();
185        let mut steps = 0u64;
186
187        // HDMA per-frame init + per-line transfers are now driven clock-accurately from
188        // `Bus::advance_master` (at V=0 and each visible line), so they stay correct even when a
189        // framebuffer DMA spans the frame boundary. The scheduler no longer sequences HDMA.
190
191        while self.bus.ppu.frame_count() == start_frame && steps < MAX_STEPS_PER_FRAME {
192            #[cfg(feature = "debug-hooks")]
193            self.bus
194                .set_debug_pc((u32::from(self.cpu.regs.pbr) << 16) | u32::from(self.cpu.regs.pc));
195            self.cpu.step(&mut self.bus);
196            steps += 1;
197
198            // HDMA is now serviced clock-accurately inside `Bus::advance_master` (so it stays
199            // line-accurate even mid-GP-DMA); the scheduler no longer polls scanline boundaries.
200
201            // Catch the SA-1 up to the master clock (no-op when no SA-1 cart is installed).
202            if self.sa1_cpu.is_some() {
203                self.run_sa1();
204            }
205        }
206    }
207
208    /// Cumulative cycles the SA-1's second CPU has executed since power-on, or `None` when no SA-1
209    /// cart is installed. A non-zero value is the SA-1 liveness signal: the second 65C816 actually
210    /// fetched + executed out of the cart ROM (many SA-1 titles run their main logic on the SA-1).
211    #[must_use]
212    pub fn sa1_cycles(&self) -> Option<u64> {
213        self.sa1_cpu.as_ref().map(|c| c.cycles)
214    }
215
216    /// The SA-1 second CPU's architectural register file, or `None` when no SA-1 cart is
217    /// installed. For the debugger overlay's Cart panel (`docs/frontend.md` §Debugger overlay).
218    #[must_use]
219    pub fn sa1_regs(&self) -> Option<Regs> {
220        self.sa1_cpu.as_ref().map(|c| c.regs)
221    }
222
223    /// The determinism seed this `System` was constructed with (`Self::new`). A TAS movie's
224    /// power-on start point records this so a replay can verify the caller reconstructed the
225    /// System with the exact same seed before calling [`crate::movie::Movie::seek_to_start`] —
226    /// a different seed gives different power-on phase alignment, breaking bit-identical replay
227    /// even against the same ROM and input log (`docs/adr/0004`).
228    #[must_use]
229    pub const fn seed(&self) -> u64 {
230        self.seed
231    }
232
233    /// Step a single CPU instruction (drives the whole machine in lockstep via the Bus).
234    pub fn step_instruction(&mut self) {
235        if !self.booted {
236            self.reset();
237        }
238        #[cfg(feature = "debug-hooks")]
239        self.bus
240            .set_debug_pc((u32::from(self.cpu.regs.pbr) << 16) | u32::from(self.cpu.regs.pc));
241        self.cpu.step(&mut self.bus);
242        if self.sa1_cpu.is_some() {
243            self.run_sa1();
244        }
245    }
246
247    /// Advance by one CPU instruction (kept for API compatibility with the old skeleton). The
248    /// real timebase advances through the CPU's bus accesses, not a bare master tick.
249    pub fn tick_one_master(&mut self) {
250        let _ = self.seed;
251        self.step_instruction();
252    }
253
254    /// Serialize the entire emulated machine — the main CPU, the whole [`Bus`] (PPU, APU, DMA,
255    /// WRAM, plus the cart's coprocessor state and battery SRAM if a cart is loaded), the
256    /// determinism seed, the boot/HDMA-line bookkeeping, and (if present) the SA-1 second CPU
257    /// plus its master-clock catch-up accounting — into a versioned binary blob
258    /// (`docs/adr/0006-save-state-format.md`). The blob leads with a 4-byte magic and a `u16`
259    /// format-version header that [`Self::load_state`] checks before trusting anything else. The
260    /// cart's ROM is never embedded (`docs/adr/0003`'s "never embed a ROM/firmware byte" posture,
261    /// already applied to every coprocessor's firmware) — restoring a cart-carrying save-state
262    /// requires the caller to have already loaded the SAME ROM onto the target `System` first.
263    #[must_use]
264    pub fn save_state(&self) -> Vec<u8> {
265        let mut w = SaveWriter::new();
266        w.write_bytes(MAGIC);
267        w.write_u16(FORMAT_VERSION);
268        self.cpu.save_state(&mut w);
269        self.bus.save_state(&mut w);
270        w.section(*b"SYS0", |s| {
271            s.write_u64(self.seed);
272            s.write_bool(self.booted);
273            s.write_u16(self.last_line);
274            match &self.sa1_cpu {
275                Some(cpu) => {
276                    s.write_bool(true);
277                    cpu.save_state(s);
278                }
279                None => s.write_bool(false),
280            }
281            s.write_u64(self.sa1_last_master);
282            s.write_u64(self.sa1_credit);
283        });
284        w.into_bytes()
285    }
286
287    /// The inverse of [`Self::save_state`].
288    ///
289    /// # Errors
290    /// [`SaveStateError::BadMagic`] if `bytes` doesn't lead with the expected magic (not a
291    /// RustySNES save-state at all); [`SaveStateError::UnsupportedVersion`] if the format version
292    /// is newer than this build understands; [`SaveStateError`] on truncated/corrupt input or a
293    /// section with unconsumed trailing bytes; or [`SaveStateError::Invalid`] if the save-state's
294    /// SA-1-second-CPU presence, or (via [`Bus::load_state`]) cart presence/SRAM size, doesn't
295    /// match this `System`'s own installed state — restoring onto the wrong ROM/board
296    /// configuration is rejected rather than silently corrupting it.
297    pub fn load_state(&mut self, bytes: &[u8]) -> Result<(), SaveStateError> {
298        let mut r = SaveReader::new(bytes);
299        if r.read_bytes(4)? != MAGIC {
300            return Err(SaveStateError::BadMagic);
301        }
302        let version = r.read_u16()?;
303        if version > FORMAT_VERSION {
304            return Err(SaveStateError::UnsupportedVersion {
305                found: version,
306                max: FORMAT_VERSION,
307            });
308        }
309        self.cpu.load_state(&mut r)?;
310        self.bus.load_state(&mut r)?;
311        let mut s = r.expect_section(*b"SYS0")?;
312        self.seed = s.read_u64()?;
313        self.booted = s.read_bool()?;
314        self.last_line = s.read_u16()?;
315        let had_sa1 = s.read_bool()?;
316        match (&mut self.sa1_cpu, had_sa1) {
317            (Some(cpu), true) => cpu.load_state(&mut s)?,
318            (None, false) => {}
319            (Some(_), false) | (None, true) => {
320                return Err(SaveStateError::Invalid(alloc::string::String::from(
321                    "save-state SA-1 second-CPU presence does not match this System's \
322                     installed cart (load the same ROM before restoring)",
323                )));
324            }
325        }
326        self.sa1_last_master = s.read_u64()?;
327        self.sa1_credit = s.read_u64()?;
328        if s.remaining() != 0 {
329            return Err(SaveStateError::Invalid(alloc::format!(
330                "SYS0 section has {} trailing byte(s)",
331                s.remaining()
332            )));
333        }
334        // SYS0 is the envelope's last section; reject anything appended after it (a corrupted or
335        // concatenated blob), the same "no unconsumed trailing bytes" posture every nested
336        // section's own load_state already enforces on itself.
337        if r.remaining() != 0 {
338            return Err(SaveStateError::Invalid(alloc::format!(
339                "save-state has {} trailing byte(s) after the SYS0 section",
340                r.remaining()
341            )));
342        }
343        Ok(())
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use rustysnes_cart::Cart;
351    use rustysnes_ppu::Region as PpuRegion;
352
353    /// A minimal synthetic LoROM header (offset `$7FC0`) with a controllable region byte
354    /// (`$7FD9` for this LoROM offset — `field::REGION` in `rustysnes-cart`'s header module,
355    /// duplicated here rather than exported, since it's a small, stable, publicly-documented
356    /// header layout, `docs/cartridge-format.md`). `region == 0x00` selects Japan/NTSC; `0x02`
357    /// selects Europe/PAL (`Header::region_from_code`'s `0x02..=0x0C -> Pal` range).
358    fn synth_rom(region: u8) -> alloc::vec::Vec<u8> {
359        let mut rom = alloc::vec![0u8; 0x1_0000];
360        let h = 0x7FC0;
361        rom[h + 0x15] = 0x20; // MAP_MODE: slow LoROM
362        rom[h + 0x16] = 0x00; // CHIPSET: ROM only
363        rom[h + 0x18] = 0x00; // RAM_SIZE: none
364        rom[h + 0x19] = region; // REGION
365        let checksum: u16 = 0x1234;
366        let complement = !checksum;
367        rom[h + 0x1C..h + 0x1E].copy_from_slice(&complement.to_le_bytes());
368        rom[h + 0x1E..h + 0x20].copy_from_slice(&checksum.to_le_bytes());
369        rom[h + 0x3C..h + 0x3E].copy_from_slice(&0x8000u16.to_le_bytes()); // reset vector
370        rom
371    }
372
373    #[test]
374    fn ntsc_cart_auto_detects_ntsc_region_on_reset() {
375        let mut sys = System::new(0);
376        sys.bus.cart = Some(Cart::from_rom(&synth_rom(0x00)).expect("ntsc header"));
377        sys.reset();
378        assert_eq!(sys.bus.ppu.region(), PpuRegion::Ntsc);
379        assert_eq!(sys.bus.ppu.region().lines_per_frame(), 262);
380    }
381
382    #[test]
383    fn pal_cart_auto_detects_pal_region_on_reset() {
384        let mut sys = System::new(0);
385        sys.bus.cart = Some(Cart::from_rom(&synth_rom(0x02)).expect("pal header"));
386        sys.reset();
387        assert_eq!(sys.bus.ppu.region(), PpuRegion::Pal);
388        assert_eq!(sys.bus.ppu.region().lines_per_frame(), 312);
389
390        // End-to-end: booting and running one full frame actually completes at the PAL line
391        // count, not just the region flag being set (proves the auto-detected region reaches
392        // the PPU's real dot/scanline timeline, not merely a cosmetic label).
393        sys.run_frame();
394        assert_eq!(sys.bus.ppu.frame_count(), 1);
395    }
396
397    #[test]
398    fn no_cart_reset_does_not_touch_region() {
399        // sync_region_from_cart is a no-op with no cart installed; region stays at whatever the
400        // Bus was constructed with (System::new always builds NTSC by default).
401        let mut sys = System::new(0);
402        sys.reset();
403        assert_eq!(sys.bus.ppu.region(), PpuRegion::Ntsc);
404    }
405
406    #[test]
407    fn new_system_unbooted() {
408        let sys = System::new(0);
409        assert!(!sys.booted);
410        assert!(sys.bus.cart.is_none());
411    }
412
413    #[test]
414    fn run_frame_without_cart_is_noop() {
415        let mut sys = System::new(0);
416        sys.run_frame();
417        assert_eq!(sys.bus.ppu.frame_count(), 0);
418    }
419
420    #[test]
421    fn reset_without_cart_does_not_boot() {
422        let mut sys = System::new(0);
423        sys.reset();
424        assert!(!sys.booted);
425    }
426
427    #[test]
428    fn system_state_round_trips_without_a_cart() {
429        let mut sys = System::new(42);
430        sys.reset();
431        sys.cpu.regs.a = 0x1234;
432        sys.bus.clock.master = 999;
433
434        let bytes = sys.save_state();
435
436        let mut fresh = System::new(0);
437        fresh.load_state(&bytes).unwrap();
438
439        assert_eq!(fresh.cpu.regs.a, 0x1234);
440        assert_eq!(fresh.bus.clock.master, 999);
441        assert_eq!(fresh.seed, 42);
442    }
443
444    #[test]
445    fn bad_magic_is_rejected_not_panicked_on() {
446        let sys = System::new(0);
447        let mut bytes = sys.save_state();
448        bytes[0] = b'X';
449
450        let mut fresh = System::new(0);
451        assert!(matches!(
452            fresh.load_state(&bytes),
453            Err(SaveStateError::BadMagic)
454        ));
455    }
456
457    #[test]
458    fn newer_format_version_is_rejected_not_panicked_on() {
459        let sys = System::new(0);
460        let mut bytes = sys.save_state();
461        // The u16 format-version field immediately follows the 4-byte magic.
462        bytes[4..6].copy_from_slice(&(FORMAT_VERSION + 1).to_le_bytes());
463
464        let mut fresh = System::new(0);
465        assert!(matches!(
466            fresh.load_state(&bytes),
467            Err(SaveStateError::UnsupportedVersion { .. })
468        ));
469    }
470}