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.
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 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.
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 (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.
- 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).
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.
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).
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.
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.
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 is a special 8-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).
Corrected in v2.4.4. This paragraph said 7 while the v2.0.0 note below said 8, and the shipped model does eight — the first opcode fetch after power-on carries cycle 8, so reset occupies cycles 0..7. The contradiction was found by an independent SystemVerilog implementation written from this document, which implemented seven and diverged from the emulator on its first record. Prose that contradicts itself cannot be the spec; the executable oracle can.
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 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.
- 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.
- 2A03 die revision (
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.
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.
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.