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


Live Monitor

Live, Code, Memory, Video, Sound, Controls and Storage follow see what’s happening → understand it in plain language → inspect the technical evidence. src/monitor/ owns the UI and deterministic analysis. It accesses the machine through MachineFacade. The host owns playback, audio and input.

Layer Responsibility
Recent activity A facade-owned bounded ring records actual SID writes and completed accelerated LOAD destinations only while Monitor is visible. Eight emulated seconds are retained, with explicit overflow and fresh epochs after interruptions.
Shared snapshot inspection-snapshot.js reuses read-only register, control, storage and memory buffers at 1, 10, 25 or 50 samples/s. The browser remembers the rate, defaulting to 10. Pause forces one final read, then navigation reuses it. Input changes, refresh() or c64-monitor-refresh invalidate paused data without clocking. Histories consume every sample, presentation is capped at 10 Hz and Live prose uses two-second windows.
Shared recording One complete detached frame feeds Video, Code and historical evidence. Requests run about once per second while visible and running, except during component About dialogs. Changing sections does not request another capture.
Analysis Views derive explanations from recorded evidence. Hidden views do not draw. Frame stories, illustrated summaries and Bauer reports are calculated on demand and cached for the frame. Summaries rank diverse findings into three-card pages, using matched pixels or known control changes. Memory decodes sampled VIC-visible bytes. Screen cells show codes or colour nibbles, while raw bitmap bits remain readable in invalid modes. An optional static reconstruction is separate from recorded Video. Renderer-source samples remain distinct from timed bus fetches. PAL phase predictions are labelled separately from verified capture evidence.
Navigation Links carry the recording, cycle and source address or instruction origin. Current settings stay separate from historical evidence. Selections survive automatic refreshes. Captured sprite previews open Memory details, with visible-pixel links into Video rather than guessing a byte from the first slot fetch.

The original display, keyboard, touch controls and header sign move into Monitor and return on close. The display keeps its renderer, input handlers and 384:272 aspect ratio. Clicking it, selecting a story stop or starting replay pauses the host. Pause retains the last complete recording and cancels unfinished work. Paused entry clocks at most two frames through the facade for one shared recording, without resuming the host. Component entry from running playback takes a bounded capture and pauses. Component navigation reuses it, and Live resumes navigation-owned pauses. Manual pause choices retain ownership. Replay never clocks the machine. Sound steps through recorded SID writes and reconstructs both chips from the frame-start registers, without audio synthesis. Missing writes suppress later reconstructed settings. Closing releases recordings, and hiding stops sampling and capture. Reset and restore allow recording to restart while running.

About dialogs reuse the chapters in Inside the C64. Help opens in content-sized dialogs, while pixel explanations and rule accordions stay inline. See the machine facade and capture model, VIC-II evidence model and performance limits.


Achievements

Achievements run on rcheevos v12.5.0 (RetroAchievements, MIT), the C library that evaluates achievement and leaderboard conditions against a machine's memory. It is vendored unmodified in native/rcheevos/rcheevos/.

Layer Responsibility
Native native/rcheevos/c64_cheevos.c is a thin glue over rc_runtime and rc_hash. build.sh compiles it with clang to wasm32-wasip1 (without the disc, console-ROM, zip and encrypted-file hashers), and tools/embed-rcheevos-wasm.mjs checks the module and embeds it as base64 in src/achievements/rcheevos-wasm-blob.js, so the app's build needs no C compiler.
Loader src/achievements/rcheevos.js instantiates the module with stub WASI imports (it never touches files or clocks) and exposes hash(bytes) (for a C64 file, its MD5), activateAchievement, activateLeaderboard, doFrame(ram), reset and clear.
Engine src/achievements/engine.js. On each c64-media event (media/now-loaded.js) it hashes the loaded files and compares them, in the browser, with the hashes from /api/games. Only a match is looked up, so no other file leaves the machine. The game's unlocked achievements are left out, the rest and its leaderboards go to rcheevos, and after every emulated frame main.js calls frame(c64.ram()). rcheevos reads the raw 64 KB of RAM, not the CPU's banked view. Its events become unlocks and leaderboard posts (src/achievements/api.js), and a set's notices (a state the player should know about, such as a trainer) are conditions armed under ids above 1,000,000,000 and re-armed after each trigger. A set's game over is armed the same way, at 1,000,000,000 itself, and opens attract mode (attract.js), which a posted score then no longer does.
Game lifetime Another file of the same game (a tape side) carries the game on. Ejecting its cartridge, or a reset with none of its files loaded, ends it. A machine reset calls rc.reset(), so every condition waits to be false again.
Off switch Options ▸ Other ▸ ACHIEVEMENTS. Off, main.js holds achievementsLive = null, so the frame loop and resets never reach the engine, and the engine ignores media events.
Server server/games.mjs loads the sets in server/sets/*.json at start (checkSet validates their shape) into node:sqlite (server/db.mjs). It retires games whose sets are gone, and serves the games, unlocks, leaderboards, activity and profiles. server/accounts.mjs holds the WebAuthn passkey accounts and recovery codes, and server/chat.mjs keeps only the mentions (@Title) of games it knows. Unlocks and new bests go to the activity feed, not the chat.
UI src/online/window.js is the Achievements window, a ui/floating-window.js that floats, docks, or fills a phone. src/achievements/browse.js draws its views, and src/chat/window.js its CHAT tab.

Each set has a spec test in test/achievements/ that parses every condition through the same rcheevos module and drives it with synthesized RAM laid out as the game keeps it. The Achievements guide says how to write one.

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