█▓▒░C64 READY.

Docs / Memory (src/memory.js): Architecture Overview

Memory (src/memory.js): Architecture Overview

A high-level map of the C64's bank-switched memory subsystem: the per-page dispatch tables that make reads/writes fast, the 6510 CPU port and PLA banking, the four access functions (read/write/peek/peekForCpu), the $D000-$DFFF I/O routing, color RAM, the shared open-bus latch, cartridges, and the reset model.

This document describes the implementation and points at the real method and field names so it can be used as a guide into memory.js. Memory is the address-space router: it owns C64 RAM and color RAM, caches the active mapping supplied by an attached cartridge device, and decides, for every address, whether an access hits RAM, a ROM, an I/O chip, color RAM, a cartridge, or the open bus. The I/O chips and cartridge-specific state machines live elsewhere; Memory just dispatches to them. See the machine orchestrator for how Memory is wired into the system.


1. Big picture

Every CPU memory access goes through read(addr) / write(addr, val). The C64's banking (which of RAM / KERNAL / BASIC / CHAR-ROM / I/O / cartridge is visible at a given address) changes at runtime via the 6510 CPU port ($01) and the cartridge EXROM/GAME lines. Rather than re-decode the banking on every access, Memory precomputes a 256-entry per-page dispatch table and rebuilds it only when banking state changes.

   CPU / VIC
      │ read(addr) / write(addr)
      ▼
   page = addr >> 8
      │
      ├─ _readPageArr[page] != null ?
      │     yes → typed-array load:  arr[addr - _readPageOffset[page]]   (FAST)
      │     no  → _readSlow(addr) → _readIO(addr)  → VIC/SID/CIA/colorRAM/cart   (SLOW)
      │
      └─ every access updates externalDataBus8  (the shared open-bus latch)

The common case (RAM, KERNAL, BASIC, CHAR-ROM, cartridge ROM, ultimax open-bus) resolves to a single typed-array indexed load. Only I/O ($D000-$DFFF when gated on) and the CPU port ($00/$01) take the slow path.


2. The per-page dispatch table

Four parallel arrays, indexed by page (addr >> 8, 0-255):

Array Meaning
_readPageArr[page] the Uint8Array to read from, or null ⇒ slow path
_readPageOffset[page] value subtracted from addr to index that array
_writePageArr[page] / _writePageOffset[page] same, for writes (null ⇒ slow / dropped)

So read is arr[addr - offset] and write is arr[addr - offset] = val. The offset trick lets a page point into RAM (offset 0), KERNAL (offset $E000), char-ROM (offset $D000), etc., all through one indexed load.

A reusable _openBus scratch buffer (256 bytes of $FF) serves ultimax open-bus pages: each such page sets offset = page << 8 so arr[addr - offset] always lands at _openBus[addr & 0xFF].

_rebuildMemoryMap() fills these tables from the current CPU port + cartridge state. It is cheap (256 iterations) and fires only on state changes: a CPU port write, a cartridge swap, or a ROM assignment (the kernal/basic/charRom property setters call it), never per CPU cycle.


3. The 6510 CPU port ($00 / $01) & banking

Addresses $00/$01 are the 6510's on-chip I/O port, not RAM, so they are special-cased at the top of read/write before the table dispatch:

Banking (_rebuildMemoryMap) reads the port pins ((cpuPort & cpuDDR) | (0x07 & ~cpuDDR)) to extract LORAM/HIRAM/CHAREN, then maps each page per the PLA truth table:

Region Visible when
KERNAL ROM ($E000-$FFFF) HIRAM
BASIC ROM ($A000-$BFFF) LORAM && HIRAM
CHAR ROM ($D000-$DFFF) !CHAREN (and CHAREN ⇒ I/O instead)
I/O ($D000-$DFFF) (LORAM || HIRAM) && CHAREN
RAM otherwise

ROM is a read shadow: a page mapped to ROM still takes writes into the RAM underneath (ultimax ROM pages drop them instead). Cartridge modes (8k/16k/ultimax) override the table above; see §6.


4. The access functions

Function Side effects? Drives bus latch? Used by
read(addr) yes (device reads clear flags / advance state) yes normal CPU reads
write(addr, val) yes yes (except $00/$01) normal CPU writes
dmaRead(addr) / dmaWrite(addr, val) yes yes an external bus master (the REU); identical to read/write except the 6510 port at $00/$01 does not answer
peek(addr) no, never pokes a device; returns the bus latch for I/O pages no VIC sampling the byte a BA-stalled CPU drives (Bauer §3.14.6 invalid c-reads)
peekForCpu(addr) no read side effects, but returns the value an I/O register read would place on the bus (_peekIO) no CPU opcode predecode (peek(pc)), so decoding doesn't ack a CIA ICR etc.

The split matters: the CPU decodes the next opcode with peekForCpu so the predecode doesn't trigger a real I/O side effect, then the cycle-1 micro-op does the actual read().

The DMA pair matters for a different reason. The $00/$01 decode lives inside the 6510, which is tri-stated while another master drives the bus, so the DRAM underneath answers instead. Without that, a 64 KB fetch covering all of RAM would write the banking latch partway through and re-bank itself mid-flight. Banking, ROM and I/O visibility are otherwise identical to a CPU access, which is what makes the $FF00 trigger (§6) useful.


5. I/O region routing ($D000-$DFFF)

When the page table maps a $Dx page to the slow path (null), _readSlow / _writeSlow route to _readIO / _writeIO:

Range Device
$D000-$D3FF VIC-II (addr & 0x3F)
$D400-$D7FF SID (addr & 0x1F)
$D800-$DBFF Color RAM (low nibble; see §7)
$DC00-$DCFF CIA1 (addr & 0x0F); joystick ports ANDed in (§8)
$DD00-$DDFF CIA2 (addr & 0x0F)
$DF00-$DF0A attached RAM Expansion, mirrored every 32 bytes to $DFEA; consulted before the cartridge
$DE00-$DFFF attached cartridge device's ioRead / ioWrite / ioPeek, or open bus

An enabled second SID claims its configured 32-byte window before the table's normal routing, consistently for read, write and peek. The window must be $20-aligned in $D420-$D7E0 or $DE00-$DFE0; outside it the primary SID's mirrors remain. CPU banking still gates all I/O. Expansion SID addresses require an empty cartridge slot, and REU mirrors conflict with every $DFxx window.

A missing device returns _openBusRead(). _peekIO mirrors _readIO without side effects.

The expansion and the cartridge occupy separate slots (reu and cartridge), so both can be attached at once; on real hardware that combination needs a port expander. The expansion answers only its eleven registers and hands everything else to the cartridge, which is conflict-free except for the three cartridge types that use IO2 themselves (see KNOWN-ISSUES). The expansion touches Memory at three points in all: this $DF00 register window, the dmaRead/dmaWrite access pair (§4), and the $FF00 trigger snoop (§6).


6. Cartridges

cartridges/registry.js maps CRT hardware-type numbers to device classes. C64Machine.loadCartridge() parses the container, creates the device, and calls Memory.installCartridge(device). Tests and older callers may use setCartridge(cfg), which constructs the same devices through a compatibility factory.

Every device owns its ROM/RAM arrays, registers, I/O decoding, serialization, and reset/freeze lifecycle. It publishes only a small mapping cache: mode, active romLo/romHi, optional writable ROML target, and an optional exceptional ROML read hook. applyMapping() copies these stable values into Memory and rebuilds the page table. Normal reads therefore retain the direct typed-array hot path; only I/O and exceptional electrical behavior call the device.

Registered devices:

Per-device electrical exceptions (Action Replay's IO1 bus-byte clocking and its $22 ROML read hook among them) live with the devices in src/cartridges/*. ROM regions follow the read-shadow rule (§3); a device can explicitly publish a writable ROML target for cartridge RAM.

The RAM Expansion's $FF00 snoop

The REU's deferred trigger has to notice a CPU write to $FF00, the mechanism that lets software bank I/O out and then start a transfer, so the DMA reaches the RAM underneath $D000-$DFFF. Watching for it on write()'s fast path would tax every store in the machine, so the watch rides the page table instead: while a transfer is armed, _rebuildMemoryMap() forces page $FF's write entry to null and parks the page's real write target in _ff00WriteArr/_ff00WriteOff. _writeSlow then performs the store as it would have and calls reu.ff00Triggered(). Arming and disarming each cost one table rebuild, and the write fast path is untouched whenever no transfer is waiting.


7. Color RAM

Color RAM ($D800-$DBFF, 1 KB) is only 4 bits wide; the upper nibble is open bus. On read, _readIO composes (externalDataBus8 & 0xF0) | (colorRam & 0x0F) (the upper nibble is typically the byte the VIC fetched in phi1 of this cycle), and re-drives the latch with that composed value. Writes store only the low nibble. This is byte-faithful to how demos read color RAM during open side borders.


8. Open bus & the shared data-bus latch

externalDataBus8 models the C64 data bus (D0-D7) when no device is actively driving it. CPU reads, CPU writes other than $00/$01, and VIC chip-bus fetches update it (the VIC holds a mem back-reference so its g-/c-/sprite-accesses re-drive the latch). An open-bus read ($DE00-$DFFF with no cart device, or the high nibble of color RAM) samples this latch instead of returning a fixed value. _openBusRead returns the latch.

The CIA1 joystick integration also lives here: _readCIA1 ANDs joyPort2 into $DC00 and joyPort1 into $DC01 (both ports also carry the keyboard matrix, so joystick bits AND in).


9. Reset model

Two layers, matching the hardware:

loadROM(data, baseAddr) is a simple bulk copy into RAM (used for raw ROM loads).


10. Bus diagnostics

c64Bus.status() reports the live latches and trace state. See Testing for per-cycle capture.


11. Key invariants & gotchas (quick reference)