Docs / 6510 CPU (src/cpu.js) — Architecture Overview
6510 CPU (src/cpu.js) — Architecture Overview#
A high-level map of the MOS 6510 CPU emulation: the cycle-accurate microop engine, instruction dispatch, addressing modes & per-cycle bus access, the interrupt model (IRQ/NMI sampling, the NMOS I-flag shadow, branch delay), reset, illegal/unstable opcodes, decimal mode, and how the chip plugs into the machine.
This document describes the implementation and points at the real method and
field names so it can be used as a guide into cpu.js (~1.6k lines). The 6510 is
the C64's CPU (a 6502 core + an on-chip 6-bit I/O port); the same CPU class
also drives the 1541 drive's 6502. The behavioural references cited in the code
(Bauer §3.x, "Bruce Clark §I-flag-delay", "groepaz NMOS 6510 Unintended
Opcodes", the VICE testprogs) are the authority for the timing quirks below.
The 6510 I/O port ($00/$01) and memory banking live in
src/memory.js, not here — the CPU just delegates every access throughmem.read/write. See the memory & banking.
1. Big picture#
The CPU is clocked one cycle at a time by machine._runMasterCycle() via
cpu.clock(). Each instruction is decomposed into a queue of micro-ops, one
per bus cycle. This is what makes the emulation cycle-exact: VIC bad-line / sprite
DMA can stall the CPU mid-instruction (RDY/AEC), interrupts are sampled at the
spec-correct cycle, and every cycle drives a real bus access at the address the
NMOS chip would use.
machine._runMasterCycle()
│ cpu.clock() (once per cycle)
▼
instructionCyclesRemaining == 0 ?
│ yes → _beginInstruction()
│ └─ _beginMicroInstruction() // interrupt? else _installOpcode(op)
│ ├─ _installOpcode() // alias pre-built program (common path)
│ └─ _queueOpcode() // live _queue*MicroOps() build (BRK/JAM/uncached)
▼
dispatch microOpFns[microOpHead++] // run THIS cycle's micro-op
│ each op tagged read / write / internal (KIND_*)
▼
instructionCyclesRemaining-- // 0 again ⇒ next opcode boundary
The live engine is _beginMicroInstruction + the _queue*MicroOps builders:
a single cycle-accurate path covering the full 256-opcode space (official +
illegal + JAM). (The module-local CYCLES array is only a reference cycle-count
table — the cycle-audit tests keep an independent copy in sync with it; the live
engine derives its timing from the micro-op programs, not this table.)
2. CPU state#
Registers and flags are stored as separate fields (no packed status byte at
rest — getP()/setP() pack/unpack on demand):
| Field | Meaning |
|---|---|
a, x, y |
accumulator + index registers |
sp |
stack pointer (page 1) |
pc |
program counter |
N V D I Z C |
status flags as separate 0/1 fields (bit 5 always reads 1; B is synthesized) |
halted |
JAM/KIL latch — only reset() clears it |
pageCrossed |
+1-cycle page-cross penalty tracker |
Micro-op queue & dispatch state:
| Field | Meaning |
|---|---|
microOpFns / microOpKinds |
parallel arrays for the current instruction: op closures + bus-kind byte (KIND_INTERNAL/READ/WRITE). Normally alias a pre-built program by reference; a runtime rewrite copy-on-writes into _scratchFns/_scratchKinds first (see §4) |
_progFns / _progKinds / _progLen |
the pre-built per-opcode micro-op program table (built once by _buildOpcodeTable) |
microOpHead / microOpLen |
ring cursor + length of the current instruction's queue |
instructionCyclesRemaining |
cycles left in the current instruction; 0 ⇒ at an opcode boundary (atInstructionBoundary()) |
tmpAddr / tmpLo / _shValue |
scratch carried across cycles of one instruction |
currentMicroOpKind |
string form of the current op's kind, read by the machine for RDY/AEC |
The parallel-array (not array-of-objects) design, plus the pre-built program table that dispatch aliases by reference (no per-instruction closure allocation — see §4), keep the hot dispatch path allocation-free on JavaScriptCore/mobile as well as V8.
3. The clock() loop & micro-op dispatch#
clock() is the per-cycle entry point. Each call:
- If at a boundary (
instructionCyclesRemaining === 0), call_beginInstruction()to build the next queue (interrupt vector or decoded opcode). Building the queue consumes no cycle of its own — the sameclock()call then dispatches cycle 1. - Sample the IRQ line into
sampledIrq(and the late-tagsampledIrqLate), preserving the previous values insampledIrqPrev/sampledIrqLatePrevfor the branch-delay logic (§6). Sampling happens only while the CPU is actually stepping — when RDY/AEC blocks the CPU, no sample occurs, so a pending IRQ's recognition is correctly held. - Dispatch one micro-op: read
microOpKinds[head]intocurrentMicroOpKind, runmicroOpFns[head](), advance the cursor. For aKIND_INTERNALop, synthesize a discarded read at PC (cpuInternalCycleDrivesBus) — the real 6510 does a bus access every cycle, even internal ones, which matters for open-bus / I/O side-effect fidelity. - Decrement
instructionCyclesRemaining.
The opcode for decode is read with peek() (a non-side-effecting memory
read) in _beginMicroInstruction; the actual bus fetch of the opcode happens
inside cycle 1's micro-op via r(this.pc). That keeps decode from
double-driving the bus.
Bus-kind tagging (RDY / AEC)#
Every micro-op is tagged read, write, or internal. The machine reads
peekNextBusKind() / peekNextRdyClass() to decide whether a VIC BA-low cycle
stalls the CPU: per Bauer §3.5, RDY halts reads (and internal/dummy cycles,
which also do a discarded read) but never writes. So a write micro-op proceeds
under BA-low; a read/internal one stalls. This is why even implied ops
(_queueSimpleMicroOp) and NOP-skips (_queueSkipMicroOps) pad with real read
micro-ops rather than no-ops — otherwise BA-low would silently pass through a
cycle the real chip would have stalled.
4. Instruction dispatch#
Dispatch is driven by a pre-built per-opcode micro-op program table.
_queueOpcode(op) is a giant switch (op) mapping each opcode to a
_queue*MicroOps builder (families below). Running that switch every instruction
would allocate a fresh set of micro-op closures per dispatch — invisible on V8
(escape analysis elides it) but the single largest idle allocation on
JavaScriptCore/mobile (~88 % of it). So the constructor runs the switch once
per opcode (_buildOpcodeTable) and snapshots each opcode's (fns, kinds, len)
into the _prog* table. At run time _installOpcode(op) aliases that program
into the live arrays by reference — O(1), zero allocation. Opcodes that mutate CPU
state at build time (BRK arms the interrupt sequence, JAM sets halted) are
auto-detected and fall back to the live _queueOpcode builder; so does any
uncached opcode.
The builders fall into families:
| Builder family | Covers |
|---|---|
_queueLoad* / _queueRead* |
LDA/LDX/LDY + read-ALU ops, all address modes (imm/zp/zp,X-Y/abs/abs,X-Y/(ind,X)/(ind),Y) |
_queueStore* |
STA/STX/STY + store-illegals (SAX) |
_queueRmw* |
INC/DEC/ASL/LSR/ROL/ROR + RMW-illegals (DCP/ISB/SLO/RLA/SRE/RRA) |
_queueSh* |
unstable store-illegals SHA/SHX/SHY/TAS (§9) |
_queueBranch |
the 8 conditional branches |
_queueJmpAbs / _queueJmpIndirect |
JMP (incl. the NMOS indirect page-wrap bug) |
_queueJsr / _queueRts / _queueRti |
subroutine + return-from-interrupt |
_queuePha/Php/Pla/Plp |
stack push/pull |
_queueBrk / _queueInterrupt |
BRK + hardware IRQ/NMI vectoring |
_queueJam |
JAM/KIL/HLT halt |
_queueTransfer / _queueSimple / _queueAccumulator / _queueSkip |
implied, accumulator-mode, multi-byte NOPs |
Each builder pushes its per-cycle closures via _readOp/_writeOp/_internalOp
(which append into the parallel arrays); _queueMicroOps then sets
instructionCyclesRemaining = microOpLen.
5. Addressing modes & per-cycle bus accuracy#
The builders model the exact 6502 bus schedule, including the dummy reads the NMOS core performs. Examples:
- Zero-page indexed (
_queueLoadZpIndexedMicroOps): cycle 3 does a dummy read of the un-indexed address before the indexed read — the classic 6502 "read frombasethenbase+X". - Absolute,X / abs,Y / (ind),Y reads (
_queueLoadAbsIndexedMicroOps,_queueLoadIndyMicroOps): a page cross adds a cycle. On no cross the read completes in cycle 4; on cross, cycle 4 does a false read at the wrong-high-byte address, then unshifts an extra read micro-op via the guarded writer_writeMicroOp(rewindsmicroOpHead, copy-on-writes off the shared pre-built template first, overwrites the slot, bumpsinstructionCyclesRemaining) so the corrected read happens in cycle 5. That inserted commit op is pre-created once per opcode in the builder, so the page-cross path allocates nothing at run time. - Store indexed (
_queueStoreAbsIndexedMicroOps,_queueStoreIndyMicroOps): always does the dummy read + takes the fixed cycle count (no page-cross optimization) — stores can't shortcut, matching hardware. - RMW (
_queueRmwAbsMicroOpsetc.): the signature read, dummy write-back-of-old-value, write-new-value sequence — the double write that e.g.INC $D019relies on to ack an interrupt twice. - JMP (indirect) (
_queueJmpIndirectMicroOps): reproduces the NMOS bug where the high byte is fetched from(addr & 0xFF00) | ((addr+1) & 0xFF)— the page-boundary wrap.
6. Interrupts#
This is the most timing-sensitive part of the CPU and the home of several NMOS
quirks. Interrupt acceptance is owned exclusively by _beginMicroInstruction
(checked at each opcode boundary, before opcode decode).
Line state & sampling#
- IRQ is level-sensitive:
setIrqLine(asserted, late)setsirqLine. Eachclock()samples it intosampledIrq. Acceptance requiressampledIrq && I==0. - NMI is edge-triggered:
setNmiLinelatches the 0→1 rising edge intonmiEdge; vectoring consumes the edge (cleared in_seqResolveVectorwhen the resolved vector is$FFFA). NMI is not masked byIand takes priority over IRQ. - A JAMmed CPU ignores both — the
haltedspin at the top of_beginMicroInstructionprecedes all interrupt logic, so a pending IRQ/NMI can never un-jam the chip (onlyreset()does). Verified bycpujamjamirq/jamnmi.
Vectoring#
_queueInterruptMicroOps(vector, clearNmiEdge) queues the 7-cycle sequence: 2
dummy PC reads, push PCH/PCL, push P (with B=0), set I, read vector low/high.
onInterruptAccept('irq'|'nmi') is a diagnostic hook fired at the boundary
(used by the VIC frame trace to measure latency). BRK (_queueBrkMicroOps) is
the software twin — same shape but pushes P with B=1 and advances PC.
The 7 sequence ops are pre-created once in the constructor and pushed by
reference — no per-interrupt closure or array-literal allocation. An IRQ fires
~once per idle frame (KERNAL raster/timer), so this was the last idle
allocation (see the performance doc). They are
capture-free: cy6 resolves its vector from a stable _intSeqBaseVector
published each call, because a mid-sequence NMI hijack (_seqResolveVector)
rewrites the live _intSeqVector.
NMOS quirks modelled#
- I-flag delay / "interrupt shadow" (
setP, CLI/SEI/PLP/RTI): CLI and PLP writeIin their final cycle, after the penultimate-cycle interrupt poll — so anI 1→0unmask is felt one instruction late. The interrupt poll reads a shadow copy,_pollI, that lagsthis.I: a normalI-write sets_iWriteThisInstr, which defers_pollI's update to the next opcode boundary. RTI is the exception: it restoresIbefore its poll, so it passessuppressIntShadow=true(updating_pollIimmediately) and has no shadow — which is what lets an un-acknowledged IRQ re-trigger immediately after RTI (Bauer §3.12: the IRQ input is state-sensitive). - CLI;SEI slips one interrupt through: once the shadowed boundary commits a
pending IRQ, a following
SEIcannot retract it — the poll that admitted it already ran with_pollI = 0. This falls straight out of the_pollIlag; no separate deferral flag is needed. - Branch-no-cross IRQ delay (
_branchIrqNoCrossDelay/_branchNmiNoCrossDelay, VICE-verified NMOS quirk): a taken branch with no page cross polls IRQ one cycle early; if that early poll missed the line, interrupt recognition is deferred by one boundary. ThesampledIrqPrev/late-tag machinery decides whether the early poll counts, so a tight branch loop with a continuously asserted IRQ doesn't defer forever (the stable-raster pattern). The taken cycle's op (_branchTakenOp, which houses this early-poll decision) and the page-cross cycle's op (_branchCrossOp) are pre-created once in the constructor and appended by reference — the decode cycle first publishes the target into_branchTarget/_branchCrossed— so a taken branch allocates nothing (it was the dominant idle allocation on JSC/mobile before).
7. Machine integration: the interrupt delay pipeline & stalling#
cpu.js exposes the pins; machine.js wires the timing. Two pieces matter:
Per-source delay pipeline (_sampleCpuInterrupts, run each master cycle
before cpu.clock()): IRQ sources (CIA1 + VIC) and the NMI source (CIA2) each
carry a tuned number of "machine stages" so net CPU-visible latency matches the
VICE oracle:
- VIC raster IRQ — 1 machine stage. Deassertion is held one extra cycle
(symmetric with the 1-cycle assertion latency) so an IRQ raised and acked by
the same
ASL/INC $D019RMW is still delivered (the Hat raster-wall case). - CIA1 IRQ — 1 machine stage; the in-CIA ICR data→IR latch supplies the other cycle, which is also what makes the 6526 interrupt-acknowledge bug behave.
- CIA2 NMI — 1 machine stage. NMI is recognised from the immediately-latched
nmiEdgeFF (it skips the IRQ line's extra sampled cycle), so a single stage already matches IRQ's net latency — adding a compensating stage makes NMI arrive a cycle too late.
RDY / AEC stalling (in _runMasterCycle): the machine reads
peekNextBusKind() and the VIC's isBaLow() / isAecLowPhi2(). A non-write
cycle under BA-low (RDY) or any cycle under AEC-low (VIC owns the bus) blocks
cpu.clock() for that cycle — and while blocked, IRQ sampling is not refreshed
(Bauer: IRQs are only recognized when RDY is high).
8. Reset#
reset() models the 6502 power-on / reset sequence: it reads the reset vector
($FFFC/$FFFD) into PC, forces I=1, and queues 7 dummy internal cycles so
the first real opcode fetch lands on the 8th cycle. sp is set to $FF
directly (VICE's post-reset presentation — path-stable across repeated resets,
unlike a relative -3 which would accumulate over warm resets). The 7 dummy
cycles are internal — they do not perform the real chip's stack-decrement
reads — and the last one clears the sampled-IRQ state so an IRQ asserted before
reset can't pre-empt the first instruction.
9. Illegal & unstable opcodes#
The full NMOS unintended-opcode set is implemented:
- Stable illegals: SAX, LAX, DCP, ISB/ISC, SLO, RLA, SRE, RRA, plus the multi-byte NOPs/skips and the implied NOPs.
LAX #imm/ LXA ($AB) and ANE/XAA ($8B) use the chip "magic constant":A = X = (A | 0xEE) & immfor LXA,A = (A | 0xEF) & X & immfor ANE. The two constants differ (0xEEvs0xEF, probed against the VICE monitor) — theflibugtorture test depends on the exact bits.- Unstable store-illegals SHA/SHX/SHY/TAS (
_queueSh*+_computeShAddr): storereg & (H+1)whereHis the target's high byte; on a page cross the high byte of the address itself becomes the stored value (the classic corruption). TAS also setsSP = A & X. The second instability — the& (H+1)term dropping off when a VIC DMA (BA) stall lands in the store's final cycles — is modelled too: the_queueSh*builders arm_shArmDrop, andnoteMidOpcodeStall()then sets_shDropAndso the AND is skipped, matching the real-HWsha*/shx*DMA-drop testprogs. - JAM/KIL/HLT (
_queueJamMicroOps): setshalted, burns the opcode fetch cycle, freezes PC; onlyreset()recovers.
10. ALU & decimal mode#
The ALU primitives (adc/sbc/cmp/asl/lsr/rol/ror/setZN) update
flags inline. BCD/decimal mode is fully modelled in adc/_sbcBCD,
including the NMOS quirk where N/V/Z reflect the binary intermediate
rather than the decimal-corrected result. The illegal RMW dispatch inlines a base
op then an ALU op (RRA = ROR then ADC, ISB = INC then SBC) so decimal-mode RRA/ISB
inherit the correct BCD behaviour.
11. Key invariants & gotchas (quick reference)#
- One micro-op = one bus cycle. The whole engine's accuracy rests on this; never collapse two bus accesses into one micro-op.
- Every cycle drives the bus, including internal/dummy cycles — required for
open-bus and I/O side-effect fidelity (
cpuInternalCycleDrivesBus). - Bus-kind tags drive RDY: reads/internals stall under BA-low, writes don't.
Padding cycles must be real
readops, not no-ops. - Opcode is decoded via
peek(), fetched viar()in cycle 1 — don't double-drive the bus at decode. - Interrupt acceptance lives only in
_beginMicroInstruction(sampled-IRQ + branch-delay path) — checked at each opcode boundary, before opcode decode. - RTI has no I-flag shadow; CLI/PLP do. Get this wrong and stable-raster / acknowledge-race demos break.
- Page cross adds a cycle for reads, never for stores.
- A JAM is only escapable via
reset()— interrupts can't recover it. - The micro-op engine is the only live path —
_beginMicroInstructionand the_queue*builders. There is no second interpreter. - The 6510 I/O port and banking are in
memory.js, not the CPU.