pub trait CpuBus {
// Required methods
fn read(&mut self, addr: u16) -> u8;
fn write(&mut self, addr: u16, value: u8);
fn on_cpu_cycle(&mut self);
// Provided methods
fn peek(&self, addr: u16) -> u8 { ... }
fn read_u16(&mut self, addr: u16) -> u16 { ... }
fn read_u16_wrap(&mut self, addr: u16) -> u16 { ... }
}Expand description
Cycle-accurate bus interface for sub-cycle PPU/APU synchronization
This trait extends the basic Bus functionality with cycle callbacks that are invoked BEFORE each memory access. This enables accurate emulation of the NES timing where PPU advances 3 dots per CPU cycle and APU advances 1 cycle.
§Architecture
The key insight from accurate emulators (like Pinky) is that on_cpu_cycle()
must be called at the START of each memory access, BEFORE the actual read/write.
This ensures that when the CPU reads $2002 (PPUSTATUS), the PPU has already
advanced to the correct state for that exact CPU cycle.
§Timing Model
CPU Cycle: |-------- read --------|
PPU Cycles: |--1--|--2--|--3--|
^ on_cpu_cycle() called here (before read)§Examples
§Implementing cycle-accurate bus for NES
use rustynes_cpu::CpuBus;
struct CycleAccurateBus {
ram: [u8; 0x800],
ppu: Ppu,
apu: Apu,
}
impl CpuBus for CycleAccurateBus {
fn read(&mut self, addr: u16) -> u8 {
match addr {
0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
0x2000..=0x3FFF => self.ppu.read_register(addr & 0x0007),
_ => 0,
}
}
fn write(&mut self, addr: u16, value: u8) {
match addr {
0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize] = value,
0x2000..=0x3FFF => self.ppu.write_register(addr & 0x0007, value),
_ => {}
}
}
fn on_cpu_cycle(&mut self) {
// Step APU once per CPU cycle
self.apu.step();
// Step PPU 3 times per CPU cycle (3:1 ratio)
for _ in 0..3 {
self.ppu.step();
}
}
}§Implementation Notes
- The CPU’s
read_cycle()andwrite_cycle()methods callon_cpu_cycle()before performing the actual memory access - This trait is essential for passing VBlank timing tests that require +/- 2 cycle accuracy when reading $2002 during VBlank transitions
- Dummy reads for page boundary crossings also trigger
on_cpu_cycle()
Required Methods§
Sourcefn write(&mut self, addr: u16, value: u8)
fn write(&mut self, addr: u16, value: u8)
Write a byte to memory
This is the raw memory write without cycle callback. For cycle-accurate
emulation, use CPU’s write_cycle() method instead which calls
on_cpu_cycle() before this method.
§Arguments
addr- 16-bit memory address to write tovalue- 8-bit value to write
Sourcefn on_cpu_cycle(&mut self)
fn on_cpu_cycle(&mut self)
Called BEFORE each memory access to synchronize PPU/APU
This callback is the heart of cycle-accurate emulation. It must:
- Step the APU once (1:1 ratio with CPU)
- Step the PPU three times (3:1 ratio with CPU)
- Handle any cycle-based events (IRQ timing, etc.)
§Critical Timing
This method is called BEFORE the memory access occurs. This is essential for accurate $2002 reads during VBlank, where the PPU state must be updated to the exact cycle before the CPU observes the flags.
§Example
fn on_cpu_cycle(&mut self) {
// Step APU
self.apu.step();
// Step PPU 3 times
for _ in 0..3 {
self.ppu.step();
}
}Provided Methods§
Sourcefn peek(&self, addr: u16) -> u8
fn peek(&self, addr: u16) -> u8
Read a byte without side effects (for debugging/disassembly)
Default implementation calls read(). Override for proper debugging support where hardware register reads have side effects.
§Notes
- This should NOT call
on_cpu_cycle() - This should NOT modify any state (e.g., don’t clear IRQ flags)
- Used by debuggers and disassemblers
Sourcefn read_u16_wrap(&mut self, addr: u16) -> u16
fn read_u16_wrap(&mut self, addr: u16) -> u16
Read a 16-bit value with page wrap (for JMP indirect bug)
The 6502 has a bug in JMP indirect where if the low byte is $FF, the high byte is read from $xx00 instead of $(xx+1)00. Note: This does NOT call on_cpu_cycle() - use CPU’s cycle-aware methods.