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 repeatedtick()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;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 (see below).
Interrupt logic¶
Per ref-docs/research-report.md §CPU interrupts:
- 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. 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/$4017access, the controller is clocked by the DMA read and then re-read by the resumed CPU cycle — the classic "DPCM conflict" double-clock (seedmc_dma_stepand thedmc_dma_get/ re-read latch inbus.rs). Validated byblargg/dmc_dma_during_read4/dma_4016_read.nes(controller shift skips bits) anddouble_2007_read.nes, plusnes-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. Seeraw_oam_dma_read/oam_dma_read_reg_active/oam_dma_putinbus.rs, bracketed by theAccuracyCoinAPU Register ActivationTest 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¶
- 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. The CPU implementation does not need to know about this — it sees only the NMI line — but tests must cover it.
- DPCM /
$4015read interaction. When CPU is halted by DMC DMA, repeated reads of$2007or$4015/$4016/$4017cause hardware bugs (perref-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/$4017double-clock / dropped-bit model is inbus.rs; see "DMA ↔ controller-read conflicts" above. - 2A03 die revision (
Cpu2A03Revision). The "unexpected DMA" extra parked-address re-read on a DMC-halt-overlaps-OAM-halt cycle is revision-gated (Rp2A03Gdefault performs it,Rp2A03Homits 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-$4014instruction fetch, never a register), so the default stays byte-identical andRp2A03Hmatches it on every oracle. Seedocs/scheduler.md§"Unexpected DMA" and ADR 0033. STA $2007before first read. PPUDATA reads are buffered: the first read returns stale data. Documented at the PPU level, not CPU.- 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.nesgolden-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 againstnestest.log. Mismatch on any line fails the test.instr_test_v5: all 16 sub-ROMs (basic, branches, flag tests, illegal opcodes, etc.). Read$6000for status:$80running,$00-$7Fcomplete with code,$81needs 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¶
XAAopcode behavior. Genuinely unstable on hardware (depends on temperature, power supply). nestest expectsA := (A | const) & X & operand; we follow that. Document deviation.- STP/JAM behavior. We halt the CPU and surface a
JammedErroron the nexttick. ROMs depending on a specific halt state are not in scope.