C64 READY.

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 Deep-dive ▸
Memory / banking src/memory.js Deep-dive ▸
Machine orchestrator src/machine.js Deep-dive ▸
1541 disk drive src/drive1541.js + gcr.js + d64.js + 6522.js Deep-dive ▸
SID 6581/8580 audio src/sid-voice.js + src/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)  │
 │     │                                                                       │
 │     │ 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           │
 └────────────────────────┘

Two threads only: the main thread runs the entire machine + 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).


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 MOS 6569 video. 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 / 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.
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.

Component File Role
main.js main.js Entry point / orchestrator: machine lifecycle (power / reset / re-wire), audio init, the rAF frame loop + framebuffer → canvas blit, chip-variant & toggle prefs, the auto-load / boot-warp sequencer, ROM status, clipboard paste, PWA install — and wiring the UI modules below.
dom.js dom.js Single inventory of every top-level DOM element reference, imported by the UI modules.
state.js state.js Shared runtime singletons (machine, loader, sidNode, running, cold-boot gates) as live bindings + setters, so modules share mutable state without importing one another.
input.js input.js All user input: control ports (joystick / gamepad / NEOS / mouse / paddle), the on-screen Key Map keyboard, the joy-key redefine dialog, and the physical keyboard → CIA1 matrix bridge. Exports updateJoyPorts / installNeosHook / _releaseAllLatched.
media.js media.js Content & peripherals UI: file library, save / load state, PRG / CRT / D64 (drives 8 & 9) / TAP loading, the disk-directory renderer, drag-and-drop, and VICE-format snapshot export. Owns the "what media is inserted" caches.
dialogs.js dialogs.js Styled confirmDialog / promptDialog, replacing the native browser dialogs.
debug.js debug.js DevTools console helpers installed on window: c64Trace (per-raster + SID-write capture), c64Vic, c64Bus.
roms.js roms.js ROM loading (localStorage cache → bundled → file picker) for KERNAL/BASIC/CHAR/1541.
crt.js crt.js .CRT cartridge file parser (header + CHIP packets).
cartridges/ cartridges/registry.js + device modules Hardware-type registry and cartridge-owned ROM/RAM banking, I/O, reset, freeze, and save-state behavior.
control-port.js control-port.js DOM-free joystick / NEOS-mouse byte builders (used by input.js).
filelibrary.js / statelibrary.js filelibrary.js / statelibrary.js Browser-local (IndexedDB) caches of loaded .prg/.d64/.crt/.tap and of save-states.
pausedemo.js / retrovibes.js pausedemo.js / retrovibes.js Lazy-loaded three.js: the attract-mode animation, and the Retro Vibes 3D model viewer (deep-dive).

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:
     1. shadow SID voices clock          (OSC3/ENV3 readback)
     2. POT sample-and-hold (÷512)
     3. apply PREVIOUS cycle's IRQ/NMI    ← the per-source interrupt delay
     4. vic2.clock(1)        [phi1]       ← VIC acts first; sets BA/AEC
     5. cia1/cia2.clock(1)   [phi1]       ← timers count, ICR→IR latch
     6. cpu.clock()          [phi2]       ← unless RDY/AEC blocks it, or load trap
     7. vic2.phi2()                        ← reconcile same-cycle CPU writes
     8. datasette.clock(1)                 ← tape edge → CIA1 FLAG
     9. drive1541.clock(steps)             ← steps=1 or 2 (true PAL ratio), or idle-skip
   ── after 19656 cycles: cia1/cia2.tick50Hz() (TOD), then blit ──

Key consequences (detailed in the machine orchestrator):


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 & performance switches

The VIC-II records the machine state it needs every cycle (register snapshots, border flip-flops, fetch state — the correctness foundation), but pixel emission is line-batched by default: a raster line's paints are deferred and replayed in one burst at line end, coalescing runs of unchanged cycles into wide segments through the same segment renderer. Any mid-line event the CPU could observe — collision-register reads, arming collision IRQs, fetch-config changes, a write into the RAM the line fetches graphics from — triggers an immediate catch-up replay, so the result is byte-identical to per-cycle rendering at every CPU-observable point (verified by a lockstep equivalence suite, framebuffer hashes, 195 reference screenshots and the demo status board; measured ~15% faster on sprite-heavy and ~26% on graphics-heavy demos). The finished framebuffer reaches the canvas through a WebGL presenter (one texture upload + one triangle per displayed frame, with an automatic 2D putImageData fallback).

Both paths are switchable at runtime for A/B or triage via src/switches.js — append ?LINE_BATCH=0 (per-cycle rendering) or ?WEBGL_PRESENTER=0 (2D presenter) to the URL, or set the same names as env vars in node harnesses. Details in the VIC-II §14.

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)
   ──► 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

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        (when present)
  $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's own local $3000-$3FFF window reads the upper 4 KB of cartridge ROMH in every CIA-selected VIC bank. This PLA path is separate from the CPU's $E000-$FFFF ROMH mapping and is used directly by freezer cartridges including Action Replay and Final Cartridge III.


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:


9. Where to look