Skip to main content

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}