Scheduler¶
References: ref-docs/research-report.md §Architecture options;
ref-docs/nesdev-wiki-technical-report.md §Emulator Architecture Guidance;
docs/architecture.md §Scheduling model; Nesdev
DMA and
APU Frame Counter.
Purpose¶
The scheduler is the heart of the cycle-accurate emulator: it advances the PPU, CPU, APU, mapper IRQ counters, and DMA controller in tight lockstep at PPU-dot resolution. It lives in crates/rustynes-core and is the single owner of the Nes run loop.
v2.0.0 "Timebase" (promoted in beta.4): the shipped scheduler is the one-clock, every-cycle-bus-access model —
Cpu::master_clockadvances by the region divider per CPU cycle (asymmetric read 5/7, write 7/5 φ1/φ2 split on NTSC; PAL 16, Dendy 15) with the PPU pulled tomaster_clock − PPU_OFFSETat both half-cycles (run_ppu_todouble catch-up);LockstepBus::cycleis the ONE canonical per-cycle counter (Cpu::cycles/Apu::cpu_cycleare assigned from it, never independently incremented); every instruction cycle is a real bus access (no busless cycles); DMA is the per-cycle interleaved unified engine; and the warm reset is a clocked sequence with the$4017re-write (seedocs/cpu-6502.md+docs/apu-2a03.md). Thetick_one_dotprimer below describes the original dot-lockstep model and the DMA controller's rules, which the unified engine preserves semantically; the full doc re-baseline lands with the v2.0.0 rc (ADR 0017).
Design¶
One tick = one PPU dot¶
fn tick_one_dot(&mut self) {
self.ppu.tick(&mut self.ppu_bus);
self.dot_count += 1;
if self.dot_count % 3 == self.cpu_phase {
self.cpu_tick();
}
}
cpu_phase is a per-power-on offset (0, 1, or 2) representing the random initial CPU/PPU alignment per real hardware. Reset does not change it. Cold power-cycle re-rolls it from a deterministic PRNG seeded by the user (default 0).
CPU tick¶
fn cpu_tick(&mut self) {
if self.dma.cycles_remaining > 0 {
self.dma.tick(&mut self.bus); // owns the bus during DMA
return;
}
if let Some(req) = self.dma.scheduled_request() {
if self.cpu.next_cycle_is_read() {
self.dma.start(req);
return;
}
}
self.cpu.tick(&mut self.bus);
if self.cpu_cycle.is_apu_cycle() {
self.apu.tick(&mut self.apu_bus);
}
}
Bus design¶
The bus owns: PPU, APU, mapper (via cart), WRAM, controllers, open-bus latch. The CPU borrows &mut Bus during tick(). PPU and APU each get their own bus trait for the things they need:
PpuBus:ppu_read/write(delegates to mapper);notify_a12(mapper IRQ).ApuBus:dmc_read(delegates to bus.read);dmc_halt_request(queues DMA);raise_irq(sets a flag the CPU consults viabus.irq_level()).
The mapper sees both buses via separate trait methods (cpu_read/write, ppu_read/write).
DMA controller¶
A small inner struct that tracks:
cycles_remaining: u16— non-zero means CPU is halted by DMA.scheduled: Option<DmaRequest>— pending DMA the controller is waiting to start.- DMA type:
OamDma { src_page: u8 },DmcDma { addr: u16, kind: Load | Reload }.
Scheduling rules per ref-docs/research-report.md §DMA:
- DMA can only halt on a CPU read cycle.
- DMC DMA gets precedence over OAM DMA.
- OAM DMA: 1 halt + (0 or 1 alignment) + 256 read/write pairs = 513 or 514 cycles.
- DMC DMA: 1 halt + 1 dummy + (0 or 1 alignment) + 1 read = 3 or 4 cycles.
Load and reload DMC DMA are not interchangeable. Load DMA is scheduled after a
$4015 enable write and reload DMA is scheduled when the DMC sample buffer
empties; the two start on different get/put phases before halt delay is
considered. The scheduler must preserve this distinction all the way to the bus
because repeated halted reads of $2007, $4015, $4016, and $4017 are
observable side effects.
The v2.0.0 "Timebase" rewrite collapsed these three drivers (standalone OAM,
standalone DMC, and the DMC-during-OAM overlap) into one per-cycle engine —
LockstepBus::unified_dma_cycle_impl in crates/rustynes-core/src/bus.rs, a
direct port of TriCNES's _6502 DMA dispatch table. The 513/514 (OAM) and 3/4
(DMC) spans are emergent from a single get/put parity label
(get = !apu.put_cycle()) rather than an owed-cycle counter. The committed
oracle floor for this engine — the five dmc_dma_during_read4 ROMs, both
sprdma_and_dmc_dma variants, and dma_timing_pin (the AccuracyCoin
CheckDMATiming reload span = 4 + the $50-$5F DMC-during-OAM landing sweep on
KEY) — is all green.
Unexpected DMA (2A03 die revision — the documented frontier)¶
nesdev DMA notes that when a DMC-DMA halt
coincides with an OAM-DMA halt (the "double-halt" overlap) some 2A03 silicon
inserts an extra re-read of the parked 6502 address bus, and this differs by
mask revision (RP2A03G vs RP2A03H). RustyNES exposes this as the additive
Cpu2A03Revision { Rp2A03G (default), Rp2A03H } config (Nes::set_cpu_2a03_revision),
gating the halted-DMC parked-address re-read that fires inside an OAM-owned read
cycle during a DMC+OAM overlap. Rp2A03G performs it (the byte-identical
default); Rp2A03H omits it.
This is an unclosed, honestly-documented frontier (ADR 0033): no public
reference emulator branches DMA behavior on 2A03 die stepping and no test ROM
captures it, and on this engine the gate — while at its mechanism-correct
location — is behaviorally inert, because the parked address during a DMC+OAM
overlap is always the post-$4014 instruction fetch (OAM drains on the next
opcode read), never a $2002/$2007/$4015/$4016/$4017 register. So
Rp2A03H is byte-identical to Rp2A03G on every oracle. The knob is a config
re-applied on load, not part of the save-state (determinism is preserved for
a fixed revision). See ADR 0033 and the cpu_2a03_revision test suite.
Region cadence¶
NTSC and Dendy can use a simple 3 PPU dots per CPU cycle cadence. PAL needs a fractional or master-clock representation because its PPU:CPU ratio is 3.2. APU frame-counter tables also differ by region; do not scale NTSC cycle counts for PAL.
Frame complete¶
The PPU reports frame_complete() when it transitions from scanline 240 to 241 (i.e., the start of vertical blank). The frontend consumes the framebuffer at this point.
Audio drain¶
The APU emits samples into a band-limited buffer continuously. The frontend's audio callback (on the cpal thread) reads from a ring buffer the run loop fills via apu.drain_samples(&mut buf) once per frame.
Determinism¶
The scheduler is fully deterministic given:
- A fixed
cpu_phase(chosen at power-cycle from a seedable PRNG). - A fixed initial WRAM pattern (deterministic seeded fill).
- A fixed sequence of controller inputs (recorded by the test harness).
- A fixed initial DMA get/put phase.
- A fixed region timing profile and reset/power-up mode.
This guarantees that save/load round-trips and a re-played input sequence produce bit-identical framebuffer + audio output. Required for movie playback, regression tests, and netplay.
Performance targets¶
- Frame cost (single-thread, no rendering): ≤ 2 ms on a 2018-era laptop x86_64 (Skylake-era).
- Frame cost including wgpu present + cpal callback: ≤ 5 ms (well under the 16.67 ms budget for 60 fps NTSC).
- Audio callback: lock-free SPSC ring buffer; never block the audio thread.
Open questions¶
- Inline vs trait dispatch. The PPU
tick()and CPUtick()are the hot paths. Initial implementation uses trait objects (Box<dyn Mapper>) for the mapper. If profiling shows mapper-dispatch overhead > 5%, switch to a monomorphized enum for the supported mappers. - SIMD for framebuffer scaling. Initial scaling is GPU-side via wgpu (sampler with nearest filter). If we add per-pixel post-processing (CRT shader, scanline), it's GPU-side as well. No CPU SIMD planned.
- Multithreading. Not planned for v1.0. The single-frame work fits comfortably in one thread.