rusty2600_core/scheduler.rs
1//! The master-clock lockstep scheduler — the heart of the emulator.
2//!
3//! **Timing master: the TIA color clock @ 3.579545 MHz (NTSC).** The 6507 CPU
4//! advances on **every third** color clock (the VCS divides the color clock by
5//! 3 to make the CPU's φ0) — LOCKSTEP, not catch-up: a mid-instruction TIA
6//! register write takes effect at the exact color clock it's written on, not
7//! after the whole instruction "finishes."
8//!
9//! The CPU drives this directly: [`System::step_instruction`] runs one full
10//! 6507 instruction via [`rusty2600_cpu::Cpu::step`], and every cycle THAT
11//! instruction consumes calls back into [`CpuView::tick_cycle`] (via
12//! [`rusty2600_cpu::CpuBus::tick_cycle`]) to advance the TIA by exactly 3
13//! color clocks, the RIOT by one tick, and the cart coprocessor by one tick —
14//! cycle by cycle as the instruction executes, not all at once when it
15//! returns. (An earlier version of this scheduler called `cpu.tick()` once per
16//! *color clock* expecting it to consume exactly one CPU cycle, but the CPU
17//! actually ran the whole instruction synchronously per call — racing far
18//! ahead of the color clock it was supposed to be locked to. That mismatch is
19//! why object-positioning routines, which time themselves against the color
20//! clock via `WSYNC` + a cycle-counted delay, never reached realistic
21//! positions. See the regression test below.)
22//!
23//! **The WSYNC / RDY beam-stall.** When the program strobes `WSYNC`, the TIA
24//! pulls `RDY` low, and the 6507 freezes BEFORE its next cycle (typically the
25//! next instruction's opcode fetch) until the TIA releases RDY at the end of
26//! HBLANK. The TIA owns the signal (`Tia::rdy_stall`); [`CpuView::rdy_stall`]
27//! exposes it to the CPU crate, which spins on [`CpuView::tick_cycle`] while
28//! it's asserted — the color clock (and RIOT/cart) keep advancing, only the
29//! CPU is frozen.
30//!
31//! Determinism contract: same seed + ROM + input ⇒ bit-identical AV. The
32//! per-power-on CPU/color-clock phase alignment comes from a SEEDED PRNG
33//! (never the OS RNG) applied as a one-time 0..3 extra-color-clock offset
34//! before the CPU's first cycle. See `docs/scheduler.md`.
35
36use rusty2600_cart::Board;
37use rusty2600_cpu::{Cpu, CpuBus};
38
39use crate::bus::Bus;
40
41/// Color clocks per CPU cycle (the VCS divides the 3.58 MHz color clock by 3).
42const CPU_DIVISOR: u8 = 3;
43
44/// Adapts the [`Bus`] to the CPU's narrow [`CpuBus`] view for the duration of a
45/// CPU step. Keeps the CPU crate free of any console-specific bus type. Also
46/// carries a `&mut` to the owning [`System`]'s running color-clock counter so
47/// [`Self::tick_cycle`] can keep it accurate without `CpuView` owning it.
48struct CpuView<'a> {
49 bus: &'a mut Bus,
50 color_clocks: &'a mut u64,
51}
52
53impl CpuBus for CpuView<'_> {
54 fn read(&mut self, addr: u16) -> u8 {
55 self.bus.cpu_read(addr)
56 }
57 fn write(&mut self, addr: u16, val: u8) {
58 self.bus.cpu_write(addr, val);
59 }
60
61 fn tick_cycle(&mut self) {
62 self.bus.tia.tick_color_clock();
63 self.bus.tia.tick_color_clock();
64 self.bus.tia.tick_color_clock();
65 self.bus.riot.tick();
66 if let Some(board) = self.bus.board.as_mut() {
67 board.tick();
68 }
69 *self.color_clocks = self.color_clocks.wrapping_add(u64::from(CPU_DIVISOR));
70 }
71
72 fn rdy_stall(&self) -> bool {
73 self.bus.tia.rdy_stall()
74 }
75}
76
77/// Owns the run loop and the lockstep timebase.
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
79pub struct System {
80 /// The 6507 CPU.
81 pub cpu: Cpu,
82 /// The Bus — owns the TIA, RIOT, cart-via-board, controllers, open bus.
83 pub bus: Bus,
84 /// Per-power-on CPU/color-clock phase alignment, from a SEEDED PRNG (never
85 /// the OS RNG): a one-time 0..3 extra color-clock offset applied before
86 /// the CPU's first cycle, so power-on alignment is deterministic per seed.
87 phase: u8,
88 /// Total color clocks elapsed since power-on.
89 color_clocks: u64,
90}
91
92/// A minimal deterministic byte generator for seeding power-on RAM/register
93/// state (ADR 0006) — SplitMix64, chosen only for being a tiny, dependency-
94/// free, well-known-good bit mixer; not a cryptographic or statistical-
95/// quality requirement, just "same seed -> same bytes, different seed ->
96/// different bytes" (the determinism contract, ADR 0004).
97struct SplitMix64(u64);
98
99impl SplitMix64 {
100 const fn next_u64(&mut self) -> u64 {
101 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
102 let mut z = self.0;
103 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
104 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
105 z ^ (z >> 31)
106 }
107}
108
109impl System {
110 /// Power on with a determinism seed (drives the phase alignment AND the
111 /// power-on RAM/register randomization, ADR 0006 — real hardware powers
112 /// up with indeterminate RAM/register contents, but "indeterminate" must
113 /// still be a deterministic function of the seed, never the OS RNG, per
114 /// ADR 0004).
115 #[must_use]
116 pub fn new(seed: u64) -> Self {
117 let mut cpu = Cpu::new();
118 let mut bus = Bus::new();
119
120 let mut rng = SplitMix64(seed);
121 // RIOT RAM (128 B, the console's only general RAM): fill 16 bytes at
122 // a time from each `next_u64()` call.
123 for chunk in bus.riot.ram.chunks_mut(8) {
124 let bytes = rng.next_u64().to_le_bytes();
125 chunk.copy_from_slice(&bytes[..chunk.len()]);
126 }
127 // A/X/Y: real 6502/6507 reset does NOT touch these, so their power-on
128 // value is whatever was last driven on the bus — seed it too rather
129 // than leaving it at a fixed 0 every run.
130 let axy = rng.next_u64().to_le_bytes();
131 cpu.a = axy[0];
132 cpu.x = axy[1];
133 cpu.y = axy[2];
134
135 Self {
136 cpu,
137 bus,
138 // `seed % CPU_DIVISOR` is in `0..3`, so the narrowing cannot truncate.
139 phase: u8::try_from(seed % u64::from(CPU_DIVISOR)).unwrap_or(0),
140 color_clocks: 0,
141 }
142 }
143
144 /// Resets the CPU using the currently installed cartridge/bus, applying the
145 /// seeded power-on phase offset first.
146 pub fn reset(&mut self) {
147 for _ in 0..self.phase {
148 self.bus.tia.tick_color_clock();
149 self.color_clocks = self.color_clocks.wrapping_add(1);
150 }
151 let mut view = CpuView {
152 bus: &mut self.bus,
153 color_clocks: &mut self.color_clocks,
154 };
155 self.cpu.reset(&mut view);
156 }
157
158 /// Run exactly one 6507 instruction to completion and return its cycle
159 /// count. This is the scheduler's sole driving primitive: every cycle the
160 /// instruction consumes advances the TIA/RIOT/cart in lockstep via
161 /// [`CpuView::tick_cycle`] as it goes (see the module doc comment) — by the
162 /// time this returns, the whole system (not just the CPU) has caught up to
163 /// the instruction's true elapsed time.
164 pub fn step_instruction(&mut self) -> u8 {
165 let mut view = CpuView {
166 bus: &mut self.bus,
167 color_clocks: &mut self.color_clocks,
168 };
169 self.cpu.step(&mut view)
170 }
171
172 /// Advance the TIA alone by exactly one color clock, with no CPU
173 /// involvement. Useful for tests / tooling that want to observe raw TIA
174 /// timing without running a program. Does NOT drive the CPU — pair with
175 /// [`Self::step_instruction`] for that.
176 pub fn tick_one_color_clock(&mut self) {
177 self.bus.tia.tick_color_clock();
178 self.color_clocks = self.color_clocks.wrapping_add(1);
179 }
180
181 /// Total color clocks since power-on (for tracing / the golden-log differ).
182 #[must_use]
183 pub const fn color_clocks(&self) -> u64 {
184 self.color_clocks
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn seeded_phase_is_deterministic() {
194 let a = System::new(42);
195 let b = System::new(42);
196 assert_eq!(a.phase, b.phase);
197 }
198
199 // ADR 0006: power-on RAM/register state is seeded-random, not a fixed
200 // constant and not the OS RNG — same seed must reproduce byte-identically
201 // (the determinism contract, ADR 0004), matching Stella's `ramrandom=
202 // <seed>` model rather than true hardware nondeterminism.
203 #[test]
204 fn seeded_power_on_ram_is_deterministic() {
205 let a = System::new(1234);
206 let b = System::new(1234);
207 assert_eq!(a.bus.riot.ram, b.bus.riot.ram);
208 assert_eq!((a.cpu.a, a.cpu.x, a.cpu.y), (b.cpu.a, b.cpu.x, b.cpu.y));
209 }
210
211 #[test]
212 fn different_seeds_produce_different_power_on_ram() {
213 let a = System::new(1);
214 let b = System::new(2);
215 assert_ne!(
216 a.bus.riot.ram, b.bus.riot.ram,
217 "different seeds should not coincidentally produce identical RAM"
218 );
219 }
220
221 // Regression guard against reverting to the old `[0; 128]` /
222 // `Cpu::new()`-hardcoded-zero power-on state this ADR replaced.
223 #[test]
224 fn power_on_state_is_not_all_zero() {
225 let sys = System::new(0xDEAD_BEEF);
226 assert!(
227 sys.bus.riot.ram.iter().any(|&b| b != 0),
228 "seeded RAM should not be all-zero"
229 );
230 }
231
232 #[test]
233 fn color_clock_advances() {
234 let mut sys = System::new(0);
235 sys.tick_one_color_clock();
236 assert_eq!(sys.color_clocks(), 1);
237 }
238
239 /// Builds a 2 KiB `Rom2K` cart (mirrored across `$1000..=$1FFF`) with
240 /// `code` placed at `$1000` and the reset vector pointing at `$1000`.
241 fn cart_with_code(code: &[u8]) -> rusty2600_cart::Cartridge {
242 let mut img = [0u8; 0x0800];
243 img[..code.len()].copy_from_slice(code);
244 // $FFFC/$FFFD mask to $1FFC/$1FFD, which lands in the cart's SECOND
245 // mirror (the 2 KiB image repeats twice across the 4 KiB window) at
246 // image offset 0x7FC/0x7FD.
247 img[0x7FC] = 0x00;
248 img[0x7FD] = 0x10;
249 rusty2600_cart::Rom2K::new(&img)
250 .map(rusty2600_cart::Cartridge::Rom2K)
251 .expect("2K cart")
252 }
253
254 // Regression test for the CPU/TIA desync: an instruction with N real 6502
255 // cycles must advance the color clock by exactly 3*N, not 1 (the old
256 // per-color-clock-tick model) and not 0 (no advancement at all).
257 #[test]
258 fn step_instruction_advances_color_clock_by_3x_its_cycle_count() {
259 let mut sys = System::new(0);
260 sys.bus.board = Some(cart_with_code(&[0xEA])); // NOP, 2 cycles
261 sys.reset();
262 let before = sys.color_clocks();
263 let cycles = sys.step_instruction();
264 assert_eq!(cycles, 2);
265 assert_eq!(sys.color_clocks() - before, 2 * u64::from(CPU_DIVISOR));
266 }
267
268 #[test]
269 fn wsync_freezes_the_cpu_but_the_color_clock_keeps_advancing() {
270 let mut sys = System::new(0);
271 // STA WSYNC ($85 $02), then NOP.
272 sys.bus.board = Some(cart_with_code(&[0x85, 0x02, 0xEA]));
273 sys.reset();
274
275 sys.step_instruction(); // STA WSYNC: sets rdy_stall, color_clock keeps moving.
276 assert!(sys.bus.tia.rdy_stall());
277
278 let scanline_before = sys.bus.tia.scanline;
279 let clocks_before_release = sys.color_clocks();
280 sys.step_instruction(); // The NOP fetch must spin until HBLANK releases RDY.
281 assert!(!sys.bus.tia.rdy_stall());
282 // The stall-spin must have carried the beam into the next scanline...
283 assert!(sys.bus.tia.scanline > scanline_before);
284 // ...consuming far more color clocks than the NOP's own 2 (6 color
285 // clocks) would alone — proving the CPU genuinely spun in place rather
286 // than racing ahead of the color clock.
287 assert!(sys.color_clocks() - clocks_before_release > 6);
288 }
289}