█▓▒░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 + 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:


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:


9. Where to look