Skip to content

CPU 2A03

DoubleGate edited this page Jun 29, 2026 · 1 revision

CPU — Ricoh 2A03 / 6502 core

References: Nesdev CPU, CPU power up state, Status flags, and CPU interrupts.

Purpose

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.

Interfaces

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 repeated tick() calls.

State

  • 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.

Status flags

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.

Behavior

Cycle-accurate execution

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.

Instruction set

  • 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; nestest requires 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.

Interrupt logic

  • NMI is edge-sensitive. Bus exposes poll_nmi() which returns true exactly 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_irq quirk): 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.

Implementation note — per-cycle bus interleaving

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.

Branch-IRQ-delay quirk (branch_delays_irq)

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.

DMA halt

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.

DMA ↔ controller-read ($4016/$4017) conflicts

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/$4017 access, the controller is clocked by the DMA read and then re-read by the resumed CPU cycle — the classic "DPCM conflict" double-clock. Validated by blargg/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 by AccuracyCoin APU Register Activation Tests.

Power-cycle vs. reset

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.

Edge cases and gotchas

  1. 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.
  2. 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.
  3. PHP / BRK B-flag. Pushed P has B set for PHP and BRK; cleared for IRQ/NMI sequences.
  4. NMI race at scanline 241 dot 0. Reading PPUSTATUS at exactly the wrong dot can suppress NMI for a frame.
  5. DPCM / $4015 read interaction. When CPU is halted by DMC DMA, repeated reads of $2007 or $4015/$4016/$4017 cause hardware bugs.
  6. STA $2007 before first read. PPUDATA reads are buffered: the first read returns stale data.
  7. 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.

Clone this wiki locally