-
-
Notifications
You must be signed in to change notification settings - Fork 0
CPU 2A03
References: Nesdev CPU, CPU power up state, Status flags, and CPU interrupts.
Implement the 2A03 CPU core (a 6502 derivative with no BCD mode) in crates/rustynes-cpu. The core is responsible for fetching, decoding, and executing instructions one cycle at a time, signaling read or write to the bus on each cycle, honoring NMI / IRQ / Reset, and being halted by DMA on read cycles only.
pub trait Bus {
fn read(&mut self, addr: u16) -> u8;
fn write(&mut self, addr: u16, value: u8);
fn poll_nmi(&mut self) -> bool; // edge-detected; consumes the latch
fn irq_level(&self) -> bool; // continuously sampled
fn dma_halt_request(&mut self) -> Option<u16>; // None = keep running
}
pub struct Cpu {
pub a: u8, pub x: u8, pub y: u8,
pub pc: u16, pub s: u8, pub p: u8,
pub cycle: u64,
}
impl Cpu {
pub fn new() -> Self;
pub fn reset<B: Bus>(&mut self, bus: &mut B);
pub fn tick<B: Bus>(&mut self, bus: &mut B); // advances exactly 1 CPU cycle
pub fn step_instruction<B: Bus>(&mut self, bus: &mut B); // for tests
}Two execution modes are supported:
-
tick()— single-cycle stepping for lockstep with the PPU. The default. -
step_instruction()— runs one full instruction, returning the cycle count. Used for nestest validation and golden-log comparisons; must be byte-equivalent to repeatedtick()calls.
- Registers A, X, Y, PC, S, P (status flags: N V _ B D I Z C — D is settable but ignored by ADC/SBC; B exists only on stack pushes).
-
Power-on state: A=X=Y=0; PC =
read16(0xFFFC); S =0xFD; P =0x24(I set, U set, others clear). - Internal latches: NMI edge detector (set during φ2 polling, raises internal signal in following φ1, persists until handled), IRQ level sampler (continuously evaluates), prefetch buffer for the next opcode, current opcode + cycle index for multi-cycle instructions.
-
No internal address decoder. All memory access goes through
Bus.
The stack-visible processor status byte is NV1B DIZC. Only N, V, D, I, Z,
and C are true CPU flags. Bit 5 is pushed as 1, and bit 4 is a transient
"B" source marker in the pushed copy only: BRK/PHP push it as 1, while
NMI/IRQ push it as 0. PLP/RTI ignore bits 5 and 4 as persistent internal
state. Debugger display of P should therefore treat those two bits as a
presentation convention rather than hardware latches.
On the NES, decimal mode is disabled. SED/CLD still mutate D, and PLP/RTI can restore D, but ADC/SBC always execute binary arithmetic.
Every cycle is either a read or a write. The execution model is a state machine where each opcode has an associated micro-program of read/write/internal cycles. Internal cycles still fire bus.read() or a no-op cycle marker to keep the bus DMA logic synchronized.
- All 151 documented 6502 opcodes.
- All 105 unofficial / illegal opcodes that commercial games depend on. Notable ones:
LAX(load A and X),SAX(store A AND X),DCP,ISC,RLA,SLO,SRE,RRA,ANC,ALR,ARR,XAA(unstable;nestestrequires the documented behavior),LAS,TAS,SHA,SHX,SHY, multi-byte NOPs. -
STP/KIL/JAM(opcodes that lock the CPU): emulator treats as halt. Only matter for malicious or experimental ROMs. -
BRK: 7-cycle sequence pushing PC+2, P with B set, then jumps via$FFFE/F. Subject to NMI hijacking.
-
NMI is edge-sensitive. Bus exposes
poll_nmi()which returnstrueexactly once per high-to-low transition. Internal CPU latch goes high on detection and stays until the NMI sequence acknowledges it (clears between cycle 4 and cycle 5 of the 7-cycle sequence). -
IRQ is level-sensitive. Bus exposes
irq_level(). The CPU samples this in lockstep with NMI polling and only acts on it if the I flag is clear. -
Polling occurs at the second-to-last cycle of each instruction. Branches are special (the
branch_delays_irqquirk): they poll IRQ at the opcode-fetch cycle (the canonical 2-cycle "second-to-last" sample point), and the operand-fetch / taken / page-cross extra cycles do not re-sample IRQ. -
Hijacking: if NMI asserts during ticks 1-4 of a BRK, BRK proceeds normally through stack pushes but the vector fetch goes to
$FFFA/B. NMI hijacks IRQ similarly. Ticks 5-6 of BRK have explicit anti-hijacking hardware and must not be re-routed.
Cpu::step drives the bus through three primitives — read1(bus, addr), write1(bus, addr, value), idle_tick(bus) — each of which performs at most one bus access and emits exactly one CPU cycle (bus.on_cpu_cycle() ticking the PPU 3 dots, the APU 1 cycle, the mapper one CPU-cycle hook, and edge-detecting NMI / IRQ on that tick). Every dispatch arm, addressing-mode resolver, RMW helper, push, pull, vector fetch, and reset / interrupt sequence is wired through these primitives, so a PPU register read or $2007 write ticks the PPU between consecutive accesses inside the same instruction instead of all-at-once after dispatch. This removes the "MMC3 / PPU sees a burst of CPU accesses with no PPU advance between them" class of bug.
A small per-instruction counter (cycles_emitted) tracks how many cycles the helpers have already burned; a trailing while cycles_emitted < cycles { idle_tick } loop in step makes up any remaining cycles. Total cycle counts are unchanged from the prior atomic-dispatch model; nestest remains zero-diff across all 8,991 instructions.
The IRQ sample also honors the I flag as it was at the start of the current instruction (irq_sample_i_flag). CLI / SEI / PLP mutate the I flag during the instruction, but the actual IRQ sample at the second-to-last cycle reads the OLD value — that's what produces the documented "exactly one instruction after CLI executes before IRQ is taken" delay. RTI is the exception: its I-flag pull occurs before the sample, so the RTI dispatch arm explicitly refreshes irq_sample_i_flag after the pull.
Real 6502 hardware polls IRQ for a branch instruction at the same point a 2-cycle untaken branch would (the opcode-fetch cycle). The operand-fetch cycle and any extra cycles for a taken / page-crossing branch do not re-sample IRQ. The effect: an IRQ that asserts during the taken / page-cross extra cycles of a branch is deferred to AFTER the next instruction, not serviced at the branch's instruction boundary.
Implementation: Cpu carries a skip_irq_sample flag. The branch dispatch arms set it before the operand fetch; for the rest of the instruction, idle_tick no longer updates irq_first_tick. NMI sampling is unaffected — the quirk is IRQ-only.
When bus.dma_halt_request() returns Some(_), the CPU stops only on the next read cycle. Write cycles (e.g., during read-modify-write instructions) cannot halt; the request stays pending. Once halted, the CPU does not advance until the bus signals the halt is over.
This is the trickiest part of the CPU/DMA interaction. The CPU must expose its current cycle-phase (read or write) to the bus so the DMA controller knows when to actually steal cycles.
A DMA cycle (DMC or OAM) that collides with a $4016/$4017 controller read clocks the controller shift register an extra time, so the running program sees a dropped or duplicated bit relative to a conflict-free read. Both collision sources are modelled in rustynes-core::Bus:
-
DMC-DMA ↔ controller read. The DMC sample-fetch GET cycle steals the CPU read cycle. When the stolen cycle lands on a
$4016/$4017access, the controller is clocked by the DMA read and then re-read by the resumed CPU cycle — the classic "DPCM conflict" double-clock. Validated byblargg/dmc_dma_during_read4/dma_4016_read.nes. -
OAM-DMA ↔ active register window. When the halted 6502 address bus is parked in
$4000-$401F, every OAM-DMA source read asserts the APU/controller chip-select and decodes on the low 5 address bits (the$20-byte register mirrors), clocking the controller shift register; an externally-driven source byte wins the bus conflict while the controller is still clocked. Validated byAccuracyCoinAPU Register ActivationTests.
Power-cycle and reset are intentionally distinct:
| State | Power | Reset |
|---|---|---|
| A, X, Y | Observed as 0 on the referenced NTSC hardware | Unchanged |
| PC | Loaded from $FFFC/$FFFD
|
Loaded from $FFFC/$FFFD
|
| S | $00 - 3 = $FD |
S -= 3 |
| I flag | Set | Set |
| C/Z/D/V/N | Observed clear at power | Unchanged |
| Internal RAM / cart RAM | Unreliable | Unchanged |
For CI, RustyNES uses deterministic seeded RAM and phase initialization. For developer accuracy work, add a randomized power-on mode before relying on any game-specific startup behavior.
- Page-crossing penalty. Reads that cross a page boundary in indexed addressing modes take an extra cycle. Writes always take the same number of cycles (no penalty), because the dummy read happens unconditionally.
-
Indirect JMP page bug.
JMP ($XXFF)reads the high byte from$XX00, not$XX00+1(page wrap). This is a 6502 bug, not a 2A03 quirk; preserved. - PHP / BRK B-flag. Pushed P has B set for PHP and BRK; cleared for IRQ/NMI sequences.
- NMI race at scanline 241 dot 0. Reading PPUSTATUS at exactly the wrong dot can suppress NMI for a frame.
-
DPCM /
$4015read interaction. When CPU is halted by DMC DMA, repeated reads of$2007or$4015/$4016/$4017cause hardware bugs. -
STA $2007before first read. PPUDATA reads are buffered: the first read returns stale data. - Internal vs external bus. AccuracyCoin still exposes differences between internal CPU data-bus effects, external open bus, and mapper-visible dummy reads. Do not "fix" SH*/TAS/LAS/XAA by only changing final register values.
RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. Licensed under MIT OR Apache-2.0. | GitHub Repository | Web Demo