Skip to content

CPU — Ricoh 2A03 / 6502 core

References: ref-docs/research-report.md §Technical deep-dive → CPU; ref-docs/nesdev-wiki-technical-report.md §CPU; 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 per ref-docs/research-report.md: 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 (per ref-docs/research-report.md §CPU). 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.

v2.0.0 "Timebase" (promoted in beta.4 — the shipped model): every instruction cycle is a real bus access — a read, a write, or a canonical dummy read (nesdev 6502_cpu.txt / Mesen2 DummyRead); there are no busless instruction cycles. The zp,X / zp,Y / (zp,X) resolvers emit the cycle-3 dummy read of the UN-indexed zero-page address; the unofficial (zp),Y RMW arms emit the unconditional unfixed-address dummy read at cycle 5 (the RMW rule, like ABS,X/Y RMW); the hardware IRQ/NMI service sequence emits its cycles 1–2 dummy reads of the interrupted PC. A fail-loud debug_assert at the trailing burn-loop guards against any dispatch arm regressing to under-emission. The cycle bookkeeping itself is one-clock: LockstepBus::cycle is the single canonical per-cycle counter; Cpu::cycles and Apu::cpu_cycle are assigned from it at single sites (never independently incremented), and Cpu::master_clock advances by the region divider per cycle with the asymmetric read 5/7, write 7/5 φ1/φ2 split (NTSC). The invariant is pinned by the one_clock_invariants harness test: residues (master_clock − 12·cycles, bus.cycle − cpu.cycles, apu.cpu_cycle − cpu.cycles) = (12, 0, 0) frame-over-frame.

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 (see below).

Interrupt logic

Per ref-docs/research-report.md §CPU interrupts:

  • 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. See the per-cycle implementation note below.
  • 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 (Phase 4 follow-up)

Cpu::step now 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 (e.g. the +1 cycle on a page-crossing indexed read, the internal cycles in JSR / RTS / RTI, the branch-taken cycle). Total cycle counts are unchanged from the prior atomic-dispatch model; nestest remains zero-diff across all 8,991 instructions.

Interrupt classification is otherwise unchanged from the "first-tick-latched" approach: at end-of-step, first_tick < last_tick arms the interrupt for the next step, first_tick == last_tick defers it (it gets serviced one instruction later — required for STA $2000-style "the write itself raises NMI" cases).

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. Once an interrupt is armed, it services unconditionally on the next step; the I-flag gate already happened at the sample point.

NMI hijacking of BRK is implemented in service_interrupt: if NMI is sampled during the dummy / push cycles of a BRK sequence, the vector read switches from $FFFE to $FFFA (and the latched NMI is consumed in-place rather than re-fired).

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 (the opcode fetch in step() has already performed the canonical poll on cycle 1); for the rest of the instruction, idle_tick no longer updates irq_first_tick. NMI sampling is unaffected — the quirk is IRQ-only. The flag is reset at the top of every step().

Covered by the branch_taken_no_cross_delays_irq_one_instruction unit test in crates/rustynes-cpu/tests/opcodes.rs. The cpu_interrupts_v2/5-branch_delays_irq ROM exercises the same behaviour but currently fails on its first sub-test (test_jmp, unrelated to branches) before reaching the branch sub-tests.

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 (per ref-docs/research-report.md §Principal engineering challenges item 3). 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; the model is verified complete against the committed oracles (no net change was needed for v1.6.0 Workstream D — the cycle-accurate engine and the v1.4.0 DMC-DMA pass already covered it; this section documents the as-built model):

  • 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 (see dmc_dma_step and the dmc_dma_get / re-read latch in bus.rs). Validated by blargg/dmc_dma_during_read4/dma_4016_read.nes (controller shift skips bits) and double_2007_read.nes, plus nes-test-roms/sprdma_and_dmc_dma/ (OAM-DMA + DMC-DMA cycle-steal alignment), all status-0 "Passed".
  • 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. See raw_oam_dma_read / oam_dma_read_reg_active / oam_dma_put in bus.rs, bracketed by the AccuracyCoin APU Register Activation Test 5-7 cases.

One further refinement — the Test 5/6 JSR $3FFE + BRK active-window-mirror path — is deliberately deferred (see the raw_oam_dma_read rustdoc): it is not independently reachable without the deferred DMC-DMA data-bus trick, touches the default build, and cannot be verified against the test in isolation, so it is not added speculatively. It converges on the future v2.0 fractional-master-clock refactor (ADR 0002).

Reset

Reset is a special 7-cycle sequence: pushes are suppressed (S decrements but no actual write occurs), then PC loads from $FFFC/D. The I flag is set. Other registers are not modified (matches real hardware; see NESdev wiki).

v2.0.0 "Timebase" (promoted in beta.4): the warm reset is a clocked SEQUENCE on the master clock — Cpu::reset runs its 8 cycles as real start_cycle/end_cycle pairs, during which the APU's scheduled $4017 re-write matures (see docs/apu-2a03.md §Reset behavior) so execution resumes ~9–12 cycles after the effective re-write, per the blargg 4017_timing bracket. cpu_phase is preserved across reset (the determinism contract).

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; Nesdev explicitly warns that commercial and homebrew bugs often come from trusting unspecified initial state.

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. The CPU implementation does not need to know about this — it sees only the NMI line — but tests must cover it.
  5. DPCM / $4015 read interaction. When CPU is halted by DMC DMA, repeated reads of $2007 or $4015/$4016/$4017 cause hardware bugs (per ref-docs/research-report.md §DMA). The CPU exposes its halted-on-which-address state so the bus can apply these reads. The complete DMC-DMA and OAM-DMA ↔ $4016/$4017 double-clock / dropped-bit model is in bus.rs; see "DMA ↔ controller-read conflicts" above.
  6. 2A03 die revision (Cpu2A03Revision). The "unexpected DMA" extra parked-address re-read on a DMC-halt-overlaps-OAM-halt cycle is revision-gated (Rp2A03G default performs it, Rp2A03H omits it), config re-applied on load, not save-state. It is a documented, unclosed frontier — no reference emulator or test ROM models the die-revision axis, and on this engine the gate is behaviorally inert (the parked address during a DMC+OAM overlap is always the post-$4014 instruction fetch, never a register), so the default stays byte-identical and Rp2A03H matches it on every oracle. See docs/scheduler.md §"Unexpected DMA" and ADR 0033.
  7. STA $2007 before first read. PPUDATA reads are buffered: the first read returns stale data. Documented at the PPU level, not CPU.
  8. 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; the address corruption and RDY-low timing need bus-cycle evidence.

Test plan

Beyond unit tests for every opcode + addressing mode:

  • nestest.nes golden-log compare: run with PC forced to $C000, capture each instruction's pre-execute state (PC, A, X, Y, P, SP, CYC, PPU dot/scanline), diff against nestest.log. Mismatch on any line fails the test.
  • instr_test_v5: all 16 sub-ROMs (basic, branches, flag tests, illegal opcodes, etc.). Read $6000 for status: $80 running, $00-$7F complete with code, $81 needs reset (we automate the reset on $81).
  • cpu_timing_test6: instruction timing for all official + unofficial opcodes except branches.
  • branch_timing_tests: page-crossing branch timing edges.
  • cpu_interrupts_v2: NMI/IRQ/BRK timing including hijacking.
  • cpu_reset: power/reset register behavior and RAM preservation.
  • instr_misc: wraparound, dummy read, and instruction-edge behavior not covered by nestest.
  • Property tests with proptest: random instruction sequences, assert that flag updates match a hand-rolled reference for ADC/SBC/CMP/BIT.

Open questions

  • XAA opcode behavior. Genuinely unstable on hardware (depends on temperature, power supply). nestest expects A := (A | const) & X & operand; we follow that. Document deviation.
  • STP/JAM behavior. We halt the CPU and surface a JammedError on the next tick. ROMs depending on a specific halt state are not in scope.