Docs / C64 Emulator Architecture (Master Overview)
C64 Emulator Architecture (Master Overview)#
A schematic, top-down view of the whole emulator: the components, how they are wired, the threading model, and the four data flows (video, audio, disk, input). This is the index document; each subsystem has its own deep-dive:
| Subsystem | Source | Deep-dive |
|---|---|---|
| 6510 CPU | src/cpu.js |
Deep-dive ▸ |
| VIC-II video | src/vic2.js + vic2-{tables,line,sprites,render}.js |
Deep-dive ▸ |
| Memory / banking | src/memory.js |
Deep-dive ▸ |
| Machine orchestrator | src/machine.js |
Deep-dive ▸ |
| 1541 disk drive | src/drive1541.js + src/gcr.js + src/media/d64.js + src/6522.js |
Deep-dive ▸ |
| SID 6581/8580 audio | src/sid/sid-voice.js + src/sid/sid-worklet.js |
Deep-dive ▸ |
| Datasette (1530 tape) | src/datasette.js |
Deep-dive ▸ |
Scope: a cycle-accurate PAL Commodore 64, running in the browser. The goal is demo/fastloader fidelity: cycle-exact CPU↔VIC↔CIA timing, open-bus behaviour, and a real 1541 drive, verified against VICE and a large registered spec-test suite.
New here? To use the emulator rather than read its internals, start with the Getting Started guide, or see the Features overview for everything it supports.
1. The whole machine at a glance#
┌─────────────────────────────────────────────────────────────────────────────┐
│ BROWSER MAIN THREAD │
│ │
│ main.js ── entry/orchestrator (UI: input/media/dialogs/dom/state/debug) │
│ │ │
│ ▼ │
│ machine-facade.js ── `c64`: the UI's only way into the machine │
│ │ │
│ │ runFrame() (once per rAF ≈ 50 Hz) │
│ ▼ │
│ machine.js ── orchestrator: clocks everything in phase │
│ │ │
│ │ per master cycle (×19656/frame): VIC → CIA → CPU → VIC.phi2 → … │
│ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌────────┐ ┌────────┐ │
│ │ CPU │ │ VIC-II │ │ CIA1 │ │ CIA2 │ │
│ │ (6510) │ │ (video) │ │ kbd/joy│ │ NMI/ │ │
│ │ │ │ │ │ TODs │ │ IEC/bank│ │
│ └────┬────┘ └────┬────┘ └───┬────┘ └───┬────┘ │
│ │ address/data bus │ │ │
│ └──────────┬───────────────┴────────────┘ │
│ ▼ │
│ Memory (memory.js) ── RAM, ROMs, banking, I/O routing, open │
│ │ bus, color RAM, cartridge │
│ │ │
│ ┌─────────────┼───────────────┬──────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ SIDProxy Datasette Drive1541 (cartridge) │
│ │ (.tap → FLAG) (1541: 6502 + │
│ │ ring buffer 2×VIA + DOS ROM) │
│ │ (SharedArrayBuffer) │ IEC bus (wired-AND) │
│ ▼ ⇅ CIA2 Port A │
│ ════════════════════════ │ │
└──────────║════════════════════════│═════════════════════════════════════════┘
║ SAB ring │ GCR ← D64
▼ ▼
┌────────────────────────┐ ┌──────────────────┐
│ AUDIO WORKLET THREAD │ │ D64 / GCR layer │
│ sid-worklet.js │ │ (disk image) │
│ 3 voices + filter + mix│ └──────────────────┘
│ → speakers │
└────────────────────────┘
The main thread runs the machine and UI; the audio worklet thread does
SID synthesis. They communicate through one lock-free
SharedArrayBuffer ring (which is why the page needs COOP/COEP headers).
WAV import also uses a temporary worker (src/media/wav-import-worker.js) for
decoding and repair, with a main-thread fallback if the worker cannot start.
The service worker (src/sw.js) handles offline caching and app updates.
2. Component reference#
Core chips (cycle-accurate, clocked every master cycle)#
| Component | File | Role |
|---|---|---|
| CPU | cpu.js |
MOS 6510. Micro-op engine: one bus access per cycle, full official + illegal opcode set, NMOS interrupt quirks. Also drives the 1541's 6502. |
| VIC-II | vic2.js + vic2-{tables,line,sprites,render}.js |
MOS 6569 video. One class across five files (core/registers, tables, line & border state machine, sprites, rendering). Cycle-incremental renderer → 384×272 framebuffer; bad lines, sprites, collisions, borders, raster IRQ; steals bus cycles (BA/AEC). |
| CIA1 | cia.js |
MOS 6526. Timers A/B, keyboard matrix (Port A col / Port B row), datasette FLAG, IRQ source, 50 Hz TOD. (The joystick-port bits are AND-merged into the $DC00/$DC01 reads by Memory, not cia.js; see §4.) |
| CIA2 | cia.js |
MOS 6526. Timers A/B, NMI source, VIC bank select (Port A bits 0-1), the IEC serial bus port. |
| Memory | memory.js |
Address-space router: RAM, KERNAL/BASIC/CHAR ROMs, per-page banking, $D000-$DFFF I/O routing, open-bus latch, color RAM, cartridges. |
Peripherals & audio#
| Component | File | Role |
|---|---|---|
| Drive1541 | drive1541.js |
Full 1541: a 6502 + two 6522 VIAs + 16 KB DOS ROM + the spindle/GCR read+write engine + stepper + IEC wiring. Writes (SAVE/scratch/format) decode back into the .d64. |
| VIA6522 | 6522.js |
The two VIAs inside the drive (serial bus + mechanics). |
| GCRDisk / D64 | gcr.js / media/d64.js |
D64 image parser + on-demand GCR bitstream encoder (VICE-matched layout). |
| Datasette | datasette.js |
C2N tape: plays .tap pulses as CIA1 FLAG edges. |
| SID worklet | sid-worklet.js |
3 voices + filter + mixing in the audio thread; consumes the SAB ring. Two selectable engines (Options ▸ Sound): the reSID model compiled to WASM from rust/sid/ (default) and the same reSID port in JS (bit-identical, ~6× more CPU). |
| SID voice | sid-voice.js |
One oscillator+envelope; shared by the worklet and the main-thread "shadow" voices that serve cycle-exact $D41B/$D41C reads. |
| drive-sounds | drive-sounds.js |
Cosmetic head-step / motor sound effects. |
Host / UI / loaders (main thread, not part of the cycle loop)#
index.html loads main.js, the entry point. The UI layer is a thin
orchestrator plus focused modules: main.js
imports and wires them, injecting the core hooks each needs through initInput()
/ initMedia() so the feature modules never import each other or main.js; the
module graph stays acyclic. Shared, reassigned singletons live in state.js as
ES-module live bindings (read directly, written through setters); every DOM
handle lives in dom.js.
The UI never touches a chip. Every module reaches the machine through
machine-facade.js, the c64 live binding in state.js (rebuilt with each
machine): commands for loading, the drives, the tape, the REU and the input
ports, read-only views of the tape, drives and REU, and the console tools
under c64.debug. Device protocols that run on the machine's clock, such as
the NEOS mouse's strobe sequencer, live in the machine itself. The boundary is
what would let the machine move to another language or a worker without
touching the UI; test/machine-facade-spec-test.js fails if a UI module
reaches past it. See the machine orchestrator.
The load-bearing modules: main.js owns the machine lifecycle, the rAF
frame loop + framebuffer blit, and the auto-load sequencer; input.js owns
every input path (physical keyboard → CIA1 matrix, control ports, the
app-shortcut registry); media.js owns file/state loading, drag-and-drop,
and the "what media is inserted" caches; roms.js, media/crt.js and
cartridges/ handle ROM loading and the cartridge device registry;
debug.js installs the DevTools console helpers. The lazy-loaded three.js
pieces (the attract-mode animation and the Retro Vibes viewer) have their own
deep-dive. §9 routes the rest.
3. The master cycle (timing backbone)#
Everything is driven by machine._runMasterCycle(), called 19656 times per
frame (312 raster lines × 63 cycles; at the 985248 Hz PAL clock that is a
~19.95 ms frame, ≈50.125 Hz, close to but not exactly 1/50 s). The phase order
models real hardware's phi1 (VIC/CIA) → phi2 (CPU)
split and is the single most important correctness invariant in the codebase:
per master cycle (abridged):
apply PREVIOUS cycle's IRQ/NMI ← the staged interrupt delay
phi1: VIC clocks first (sets BA/AEC), then the CIAs count
phi2: CPU steps — unless RDY/AEC/DMA blocks it, or the load trap fires
then: VIC phi2 reconciliation, datasette edge, 1541 drive step(s)
── after 19656 cycles: cia1/cia2.tick50Hz() (TOD), then blit ──
The full annotated cycle (every step, in order, with the rationale for each) is the machine orchestrator §3. Key consequences:
- A CPU write this cycle is visible to VIC/CIA only next cycle.
- Bus stalling: the VIC's BA/AEC lines (and REU DMA) stall the CPU on the exact cycles real hardware would; the rules live in the machine orchestrator §4.
- Interrupts are applied from the previous cycle's pending state: that lag is the modelled IRQ/NMI latency, staged per source to match the VICE oracle.
4. The four data flows#
Video (VIC-II → screen)#
RAM / color RAM / char ROM ──► VIC-II g/c/sprite accesses (per cycle)
──► cycle-incremental render ──► fb32 (384×272 RGBA framebuffer)
──► (end of frame) main.js blits ImageData to <canvas>
The VIC renders each cycle's ~8-pixel slice as the beam passes, so mid-line CPU reads of the collision registers see cycle-accurate state. See the VIC-II.
Rendering pipeline#
The VIC-II records cycle state with compact history, schedules stable sprites by output interval, and renders graphics from captured fetch bytes. Eligible lines batch output until line end; CPU observers trigger immediate catch-up. Tracing and armed collision IRQs select live rendering. Cartridge and tracing lines use the RAM-reading graphics path. See the VIC-II §8 and §14.
The finished framebuffer uses a WebGL presenter with an automatic 2D fallback. CRT presets use a shader when supported and CSS overlays otherwise. Presentation switches remain available for diagnostics; see Performance.
Audio (CPU → SID → speakers): crosses the thread boundary#
CPU writes $D400-$D41C ──► Memory ──► SIDProxy.write ──► machine._sidWrite
──► SharedArrayBuffer ring (cycle-stamped) ──► [worklet thread]
──► sid-worklet: 3 voices + filter + mix ──► AudioContext → speakers
└─► main-thread shadow voices serve $D41B/$D41C reads
The shadow voices duplicate just the oscillator+envelope on the main thread so cycle-exact voice-3 readback (RNG loops, model detection) works without the worklet's millisecond latency. Full sound-generation detail (waveforms, combined waveforms, filter, ADSR, DAC, 6581-vs-8580 differences) is in the SID; the thread/ring plumbing is in the machine orchestrator §7.
Disk (D64 → drive → C64)#
D64 sectors ──► GCRDisk: 4-to-5 GCR bitstream (per track, VICE layout)
──► drive spindle shifts bits at the speed-zone rate
──► SYNC detect → byte framing → byte-ready (VIA2 CA1 / SO pin)
──► drive 6502 runs DOS ROM, decodes GCR, bit-bangs IEC
──► IEC bus (wired-AND in machine._syncIecBus) ⇄ C64 CIA2 Port A
Two modes: a fast KERNAL load trap ($FFD5, reads the D64 directly) or full True Drive Emulation (the real hardware path, required for fastloaders). See the 1541 drive.
Input (keyboard / joystick / paddle)#
DOM key / gamepad / mouse events (input.js) ──► c64 facade
──► CIA1 keyboard matrix (Port A col select → Port B row read)
──► joyPort1/joyPort2 bytes ANDed into CIA1 $DC01/$DC00 by Memory
──► paddle X/Y → SID POTX/POTY (512-cycle sample-and-hold)
──► CIA1 PB4 / joystick-1 fire → VIC light-pen pin
──► NEOS mouse: motion and buttons in; the machine's own strobe
sequencer answers CIA1 port writes, and its right button drives POTX
5. Memory map & banking (the bus)#
The CPU's view of $0000-$FFFF is bank-switched by the 6510 port ($01) and the
cartridge lines. Memory precomputes a 256-entry per-page dispatch table so each
access is a single typed-array load; only I/O and the CPU port take a slow path.
$0000-$0001 6510 I/O port (DDR / data) ← banking control + datasette
$0002-$9FFF RAM
└ $8000-$9FFF cartridge ROML overlays it when a cartridge maps one
$A000-$BFFF BASIC ROM / RAM / cartridge ROMH
$C000-$CFFF RAM
$D000-$DFFF I/O ┬ $D000 VIC-II ($D000-$D3FF) ← or CHAR ROM (CHAREN=0)
├ $D400 SID ($D400-$D7FF)
├ $D800 color RAM ($D800-$DBFF, 4-bit + open-bus nibble)
├ $DC00 CIA1 ($DC00-$DCFF)
├ $DD00 CIA2 ($DD00-$DDFF)
└ $DE00 cartridge I/O ($DE00-$DFFF)
$E000-$FFFF KERNAL ROM / RAM / cartridge ROMH
A shared externalDataBus8 latch, driven by both CPU and VIC accesses, models
open-bus reads (e.g. the color-RAM upper nibble, empty $DE00). See
the memory & banking.
In Ultimax mode the VIC also gets its own window into cartridge ROMH: a PLA
path separate from the CPU's $E000-$FFFF mapping, relied on by freezer
cartridges; see memory & banking §6 and the
VIC-II §4.
6. Interrupt topology#
VIC raster/collision/lightpen ─► irqHandler ─┐
CIA1 timers/FLAG ─────────────► irqHandler ─┼─► (per-source delay pipeline)
│ ─► CPU IRQ pin (maskable, I flag)
CIA2 timers ──────────────────► irqHandler ──────► CPU NMI pin (edge, non-maskable)
The machine stages each source independently so net latency is uniform; the CPU then applies the NMOS recognition rules (I-flag shadow, branch-delay, the acknowledge race). Details split across the machine orchestrator §5 and the 6510 CPU §6.
7. Reset & startup#
main.js: load ROMs (cache→bundled→picker)
└─► machine.loadROMs() ─► reset()
├─ mem.reset() seed DRAM with VICE XOR pattern (cold boot)
└─ _resetChips() CIA/VIC/SID reset, CPU fetches $FFFC vector,
reset drive + datasette, sync IEC bus
reset() is a cold power cycle (RAM regenerated); softReset() is a /RESET
pulse (RAM preserved). Program entry points: PRG injection (fakes the KERNAL
post-LOAD state), disk auto-LOAD, cartridge, or tape.
8. Fidelity strategy#
Why the timing is this fussy, and how it is kept honest:
- Cycle-exact, phi-aware ordering is the foundation; demos rely on CPU↔VIC↔CIA interactions resolving on the right half-cycle.
- Open-bus modelling via the shared data-bus latch; torture-class programs read floating-bus values.
- A real 1541 (not just a load trap): fastloaders bit-bang the IEC bus and count cycles.
- Chip-variant toggles: VIC 6569 / 8565, SID 6581 / 8580, because demos detect the chip and branch.
- VICE as the oracle: behaviour is cross-checked against VICE and pinned by a
large registered spec-test suite (
test/*-test.js, run vianpm test); most reference demos are byte-identical frame-for-frame. Risky/unmodelled edge cases are documented rather than force-fixed (see Known Issues).
9. Where to look#
- Adding/altering an opcode or interrupt timing →
cpu.js+ 6510 CPU. - A rendering / sprite / raster bug →
vic2.jsand its siblings (vic2-line/-sprites/-render.js) + VIC-II. - Banking / open bus / cartridge →
memory.js+ memory & banking. - Cycle ordering, IRQ delay, IEC wiring, loading →
machine.js+ machine orchestrator. - Disk / fastloader / GCR →
drive1541.js& friends + 1541 drive. - Frame loop / audio init / machine lifecycle / prefs / PWA →
main.js; keyboard / joystick / gamepad / mouse →input.js; file & state loading, snapshots, disk directory →media.js; confirm/prompt dialogs →dialogs.js; DOM refs →dom.js; shared runtime state →state.js; console debug tools →debug.js; the UI's way into the machine →machine-facade.js. - CIA timers / keyboard / TOD →
cia.js. SID synthesis →sid-worklet.js/sid-voice.js. Tape →datasette.js.