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}