Docs / 1541 Disk Drive (src/drive1541.js + gcr.js + d64.js + 6522.js) — Architecture Overview
1541 Disk Drive (src/drive1541.js + gcr.js + d64.js + 6522.js) — Architecture Overview#
A high-level map of the Commodore 1541 floppy-drive emulation: the drive as a self-contained computer (6502 + two 6522 VIAs + DOS ROM), the spindle/GCR read+write engine, the IEC serial bus, the stepper motor, the D64↔GCR encode/decode pipeline, and the two ways the host talks to it (KERNAL load trap vs. True Drive Emulation).
This document describes the implementation and points at the real method and field names so it can be used as a guide into the four source files. The 1541 is a full peripheral computer with its own CPU and firmware; the emulation runs that firmware cycle-accurately so cycle-counted fastloaders work. See the machine orchestrator (§6) for how the drive plugs into the C64.
The drive's 6502 is the same
CPUclass the C64 uses — see the 6510 CPU. The 16 KB DOS ROM in use is the 1541-II (1541-II.251968-03.bin).
1. Big picture#
A real 1541 is not a "dumb" drive — it is a microcomputer that receives commands
over the serial (IEC) bus, runs its DOS ROM to seek the head and read raw GCR
bits off the spinning disk, and shifts decoded bytes back to the C64. On a SAVE,
scratch, rename, or N: format it runs the same machinery in reverse, writing
fresh GCR onto the disk. This emulation reproduces the whole chain — both
directions — so copy-protection and fastloader tricks (which bypass the DOS and
bit-bang the bus / count cycles) behave correctly.
READ:
D64 image (sectors)
│ GCRDisk.getTrackStream(track) gcr.js
▼
GCR bitstream (4-to-5 encoded, sync marks, gaps — VICE-matched layout)
│ _advanceSpindle() shifts bits at the speed-zone rate
▼
read head → SYNC detect → byte framing → lastGCRByte
│ VIA2 Port A + byte-ready → CA1 / SO pin (gated by SOE)
▼
Drive 6502 runs DOS ROM ($C000-$FFFF) → decodes GCR, talks IEC
│ VIA1 Port B (ATN/CLK/DATA via 7406 inverters)
▼
IEC bus (wired-AND in machine._syncIecBus) ⇄ C64 CIA2 Port A
WRITE (SAVE / scratch / N: format):
Drive 6502 in write mode (VIA2 CB2 = manual-low, Port A = output)
│ byte stored to VIA2 Port A → writePortA latches it
▼
_advanceSpindle() shifts the byte's bits ONTO the track buffer, pulsing byte-ready
▼
mutated GCR track → gcr.js decodeTrackStream() → d64.writeSector() → D64 image
Two host-integration modes (chosen in machine.js):
- KERNAL load trap (TDE off): the machine intercepts the KERNAL LOAD entry
$FFD5and reads the file straight from the D64. Fast, but only handles standard LOADs (andSAVE/format are never trapped, so they still reach the real drive). - True Drive Emulation (TDE on — the default):
$FFD5is left to the real IEC protocol, so the fullDrive1541services LOADs, fastloaders, protected disks, and all writes.
These modes do not decide whether device 8 exists. If no 1541 ROM is loaded,
machine.drive1541 is null and device 8 consumes no per-cycle drive work. Once
a 1541 is attached, it remains a live bus device in both modes: trap-mode LOADs
still bypass DOS at $FFD5, but code that calls lower KERNAL IEC routines or
bit-bangs $DD00 can talk to the drive CPU/VIA state.
2. Components#
| File | Class / role |
|---|---|
drive1541.js |
Drive1541 — the orchestrator: a 6502 CPU + VIA1 + VIA2 + ROM + RAM + the spindle/GCR read+write engine + IEC wiring + stepper |
6522.js |
VIA6522 ×2 — VIA1 (serial bus) and VIA2 (mechanics + read/write head); timers, ports, CA1/CA2, IRQ |
gcr.js |
GCRDisk — wraps a D64 and synthesizes a raw GCR track bitstream on demand (4-to-5 encode, sync, gaps) |
d64.js |
D64 — parses the disk image: sectors, BAM, directory, file chains, $-directory PRG synthesis |
drive-sounds.js |
cosmetic head-step/motor sound effects (not part of the data path) |
3. The drive as a computer (Drive1541)#
The constructor builds a complete machine:
- CPU:
new CPU(this)— the drive 6502, withDrive1541itself as the memory object (it implementsread/write/peekForCpu). - RAM: 2 KB (
$0000-$07FF, mirrored up to$17FF). - ROM: 16 KB DOS at
$C000-$FFFF(a 2-byte PRG header is stripped if the dump includes one). - VIA1 (
$1800-$1BFF, mirrors every 16 bytes) — the IEC serial bus. - VIA2 (
$1C00-$1FFF, mirrors every 16 bytes) — drive mechanics + read/write head.
$0000-$07FF 2KB RAM (+ mirrors to $17FF)
$1800-$1BFF VIA1 (IEC serial bus)
$1C00-$1FFF VIA2 (mechanics / read+write head)
$C000-$FFFF 16KB DOS ROM
_initCpu() boots the 6502 from the ROM reset vector ($FFFC/$FFFD).
_wireCallbacks() connects the VIA port read/write callbacks (re-applied after
reset() recreates the VIAs).
Clock loop#
clock(cycles) steps the drive one cycle at a time, peripherals before CPU:
for each cycle:
via1.clock(1)
via2.clock(1)
if (motorOn) _advanceSpindle(1) // GCR bit shift, byte framing, SO/CA1
cpu.clock() // drive 6502 micro-op
Peripherals tick first so a GCR byte-ready V-flag latch or a VIA timer IRQ that
occurred this cycle is visible to the CPU's micro-op when it samples them —
reversing the order made BVS/IRQ-poll loops miss events by a cycle (looks like
a read error → blinking LED). The machine clocks an attached drive through a
16.16 drive:C64 accumulator: default true PAL ratio is 1 MHz / 985248 Hz
(driveTrueClockRatio), while the legacy switch pins it to exact 1:1 lockstep.
The attached drive is not always full-clocked. machine._runMasterCycle()
can enter idle-skip once the IEC bus has been quiet long enough and
Drive1541.canIdleSkip() proves that the drive CPU is parked at a known ROM or
fastloader idle loop, on an instruction boundary, with motor/LED/IRQ off and the
serial lines released. While skipped, the CPU loop is not run per cycle;
deferIdleCycle() accounts for elapsed drive time and settleIdleCycles() later
advances VIA timers when a bus edge or timer wake arrives. Bus changes wake the
drive immediately via setIecLines().
4. VIA1 — the IEC serial bus interface#
VIA1 Port B is the serial bus. A 7406 open-collector inverter sits between the VIA pins and the bus lines, so a VIA register bit of 1 corresponds to the bus line being pulled LOW (asserted). Bit layout:
| Bit | Function |
|---|---|
| 0 | DATA IN |
| 1 | DATA OUT (0 = pull low) |
| 2 | CLOCK IN |
| 3 | CLOCK OUT |
| 4 | ATNA (ATN acknowledge) |
| 5,6 | device-# jumpers (device 8 → 00) |
| 7 | ATN IN |
readPortBpulls the live bus state first (busSyncCallback→machine._syncIecBus) — the drive can spin polling$1800without writing, so the inputs must reflect the current bus, not a cached value. It folds in the device-number jumpers and the inverter pull-ups for any output pin a fastloader flipped to input.writePortBrecomputes the drive's output pins (_manualDataOut_pin,clkOut_pin,_atna_pin) from the output register masked by DDR, then_refreshIecOutputs()._refreshIecOutputsimplements the 1541's hardware DATA logic:DATA is pulled low iff (PB1 == 0) OR (ATNA_pin XOR ATN_bus)— the automatic ATN-acknowledge that lets the C64 detect the drive's presence. It notifies the bus (busSyncCallback) only when an output actually changed, to avoid infinitesetIecLines → _refreshIecOutputs → callbackrecursion.- ATN falling edge triggers VIA1 CA1 (
setIecLines→via1.triggerIrq(1)), the interrupt the DOS uses to enter its command state.
The bus itself is wired-AND and arbitrated in machine._syncIecBus() — see
the machine orchestrator §6. The drive sees the
reflected composite bus (setIecLines), never just its own output: it must
re-sync on every VIA1-PB read or the wired-AND can deadlock.
5. VIA2 — drive mechanics & read/write head#
- Port A is the head's data byte. On a read,
readPortAreturns the GCR byte from the read head (lastGCRByte, whatever the spindle last framed). On a write, the DOS stores the outgoing GCR byte here andwritePortAlatches it (_lastWrittenByte) for the spindle to shift onto the track (§7). - Read vs write is selected by VIA2 CB2 (PCR bits 5-7): manual-output-low
(
PCR & $E0 == $C0) = write, high ($E0) = read._isWriteMode()also guards on Port A DDR =$FF(all output), which the DOS sets only while writing. - Port B is the mechanics:
| Bit | Function |
|---|---|
| 0-1 | stepper motor phases (low 2 bits of the 4-phase pattern) |
| 2 | spindle motor on (active high) |
| 3 | activity LED |
| 4 | write-protect sense (input, active low — 0 = protected, 1 = write enabled) |
| 5-6 | bit-rate / speed-zone select |
| 7 | SYNC detect (input, 0 = sync found) |
writePortB tracks motor on/off (starting the spin-up window), latches the speed
zone, and decodes the stepper phase from the output register (ORB), not the
masked pin value — so a DDR-only write doesn't synthesize a spurious step.
- CA1 / CA2 = byte-ready / SOE. When the spindle frames a byte, it pulses
VIA2 CA1 (
triggerIrq(1)— latches the IFR flag, readable at$1C0D, cleared by reading$1C01) and, if SOE is enabled, asserts the 6502 SO pin. SOE is CA2: the DOS writesPCR=$EE(CA2 = 111) during sector reads so byte-ready reaches the CPU's V flag (theBVCread loop); seek/gap phases leave CA2 ≠ 111 to suppress it. The same byte-ready pulse paces a write — it fires every 8 bits shifted onto the track, so the DOS's write loop hands over the next byte in time.
VIA6522 internals#
The shared VIA6522 models the two timers (T1 free-run/one-shot driving the DOS
controller scheduler IRQs; T2 one-shot), the IFR/IER interrupt logic
(irqState, triggerIrq, clearIrq), and the port/handshake registers. It is a
1541-focused subset, not a complete 6522. Both VIAs feed _updateIrq() →
cpu.setIrqLine(via1.irqState || via2.irqState).
6. Stepper motor & head positioning#
The head position is tracked as a half-track index (currentHalfTrack, 2..84
→ tracks 1..35+). The stepper is a 4-phase Gray-coded motor: each phase
transition moves the head one half-track, with direction encoded in the
transition (_stepHeadByPhase):
- step in (higher tracks): phase 0→1→2→3→0
- step out (lower tracks): phase 0→3→2→1→0
- a 2-step (illegal) transition doesn't move the head reliably.
Decoding from the phase pattern itself (rather than a DOS target-track shortcut) is essential because fastloaders write phases directly, bypassing the DOS job queue. The head rests on track 18 (half-track 36) at power-up, matching VICE's deterministic reset position. A step optionally arms a head-settle window (§11).
7. The spindle / GCR read+write engine (_advanceSpindle)#
This is the heart of the read path. Per cycle (while the motor is on):
- Bit clock:
bitCycleAccumaccumulates cycles; one bit is shifted everyCYCLES_PER_BYTE[speedZone] / 8cycles. The four speed zones ([32,30,28,26]cycles/byte, indexed by the VIA2 PB5-6 density bits) model the constant-angular-velocity zones — outer tracks pack more bits. - Track fetch: on a head move (
trackDirty), pull the GCR stream for the current track fromGCRDiskand rescale the bit position so rotation phase is preserved across the step. - Bit shift: read the next bit from the track bitstream (which loops — the
disk spins continuously), shift it into
_shiftReg. - SYNC detection: a run of 10+ consecutive 1-bits is a sync mark — drives
the VIA2 SYNC bit low (
_syncBit = 0x00), and there is no valid byte framing while in sync. The first 0-bit after sync re-establishes byte alignment. - Byte framing: every 8 shifted bits outside sync forms a byte →
lastGCRByte, and fires byte-ready (VIA2 CA1 + SO pin if SOE on, §5).
So the C64↔drive read protocol emerges from the same primitives real hardware uses: the DOS (or a fastloader) waits on SYNC, then reads bytes paced by the byte-ready pulses, decodes the 4-to-5 GCR back to data, and verifies the checksum.
Writing is the mirror image, taken while the DOS holds the head in write mode
(§5). Instead of framing bits off the track, the engine shifts the latched
_lastWrittenByte MSB-first onto the current track buffer at the head position,
and pulses byte-ready every 8 bits so the DOS feeds the next byte. Sync ($FF) and
the header/data blocks are simply the bytes the DOS emits, so no special-casing is
needed. The mutated per-track buffer is decoded back to the D64 image on demand
(GCRDisk.commitDirtyTracks(), §8). A disk is written only when it presents PB4
high (write enabled); the DOS refuses to write a protected disk (error 26).
8. GCR encoding & decoding (gcr.js)#
GCRDisk turns D64 sectors into the raw bitstream the spindle reads, and folds
head writes back the other way. Encoding must match the standard on-disk layout
byte-for-byte or cycle-counted fastloaders reject headers. Per track
(buildTrackStream):
- 4-to-5 GCR: every 4 data bits → 5 GCR bits via
GCR_ENCODE, guaranteeing no long runs of 0s (which the read electronics couldn't clock) and reserving 10+ 1-bit runs for sync marks. - Per sector: 5 bytes of
$FFheader sync → header block (08, checksum, sector, track, id2, id1, $0F, $0F— the trailing$0F $0Fare load-bearing for some decoders) → 9-byte header gap → 5 bytes data sync → data block (07+ 256 data + checksum + 2 pad) → inter-sector tail gap. - Track sizing & gaps are zone-indexed (
TRACK_SIZE,TAIL_GAP) to the standard D64 layout; gap filler is$55. - Streams are built lazily and cached per track (
_cache).
Decoding (write-back). The inverse path folds the mutated track buffer back into the image:
decodeTrackStream(stream)walks the buffer as a circular bitstream, hunts sync (≥10 one-bits) exactly as the read head does, then reads 5-bit GCR groups throughGCR_DECODE(the exact inverse ofGCR_ENCODE). Each$08header supplies the (track, sector) for the$07data block that follows it; both checksums are verified and any bad/invalid block is skipped — never written — so a half-written or garbage track can't corrupt already-good sectors.markTrackDirty()flags a track the write head mutated;commitDirtyTracks()decodes each dirty track and writes its sectors into the D64 viad64.writeSector(). The encode↔decode round-trip is lossless (683/683 sectors), so re-writing untouched sectors is idempotent — only genuinely changed data moves.
Note the two opposite zone numberings:
zoneForTrack(outer→inner 0..3, used forTRACK_SIZE/TAIL_GAP) vs. the VIA2 PB5-6 density bits (used forCYCLES_PER_BYTE). The drive'sspeedZoneBitsForTrackuses the latter.
9. The D64 image (d64.js)#
D64 parses a standard 35-track (683-sector) or extended 40-track image:
SPT— sectors-per-track table (21 on tracks 1-17 down to 17 on 31-35), the CAV zone structure.readSector(track, sector)— raw 256-byte sector access (whatgcr.jsencodes);writeSector(track, sector, bytes)is the write-back primitive (marks the image dirty), used bygcr.js's decoder.createBlankD64(name, id)— synthesizes a fresh empty formatted image (empty BAM at 18/0 + directory at 18/1, 664 blocks free) for the FORMAT action.- Directory (
_parse): reads the BAM (track 18 sector 0) for disk name/ID/DOS-type/free-blocks, then walks the directory chain (track 18 sector- collecting file entries (name, type, start track/sector, block count).
loadFile(name)— finds an entry (case-insensitive,*wildcard) and follows its sector chain (readChain) to return the raw file bytes (PRG: first 2 bytes = load address). Used by the KERNAL load trap.buildDirectoryPRG()— synthesizes the disk directory as an in-memory BASIC program soLOAD "$",8+LISTshows the catalog.
10. Idle-skip optimisation#
A drive spinning in its ROM idle loop (or a fastloader idle loop) with the
spindle stopped and all bus lines released does no useful work. canIdleSkip()
detects that state (PC in the idle loops, no IRQ pending, motor/LED off, bus
released) and the machine skips the CPU for those cycles
(deferIdleCycle/_skipDriveIdleCycle), batching the deferred VIA time and
settling it (settleIdleCycles) when a bus change or a timed wake
(idleSkipWakeCycles, derived from the VIA timers) arrives. This keeps an idle
drive cheap without losing the next ATN edge. See the machine orchestrator §6.
11. Mechanical timing models (opt-in flags)#
Physical delays that suppress valid byte framing while the drive is not read-stable. The DOS tolerates instant behaviour (it has its own delay loops), and these can slow loads / perturb cycle-counted fastloaders, so they are feature-flagged:
| Flag | Default | Models |
|---|---|---|
DRIVE_MOTOR_SPINUP_ENABLED |
off | ~300 ms (300k cy) after motor-on before stable read speed |
DRIVE_HEAD_SETTLE_ENABLED |
off | ~10 ms (10k cy) after a half-track step before reads are stable |
DRIVE_SO_DELAY_ENABLED |
off | VICE's P1-aligned delay between the bit-8 boundary and the CPU's V-flag set (risky above ~18 cy) |
All three default off — the DOS has its own delay loops and instant framing is safe, so they exist mainly for A/B experiments. When spin-up / head-settle is enabled its window keeps the disk turning (bit position advances) but suppresses SYNC and byte framing until it elapses.
12. Reset#
reset() clears RAM, recreates both VIAs (and re-wires their callbacks),
restores the head to track 18, reseeds the speed zone and stepper phase to be
consistent with that track, clears all IEC line trackers and the read-engine
state, and re-boots the CPU from the ROM reset vector. setTrueDrive (in the
machine) additionally runs the drive forward until its ROM self-test reaches the
idle scheduler before the first LOAD, so the C64 doesn't time out racing the boot.
13. Key invariants & gotchas (quick reference)#
- The drive is a real computer — its 6502 runs the DOS ROM; the read protocol emerges from GCR bits + byte-ready, not from shortcuts. That's why fastloaders work.
- Peripherals clock before the CPU each cycle, and an attached drive uses the true PAL drive:C64 ratio by default, with a legacy 1:1 switch for exact lockstep investigations.
- VIA1 PB uses 7406 inverters: register bit 1 ⇒ bus line LOW. The drive must re-sync the bus on every PB read or the wired-AND can deadlock.
- Byte-ready drives both CA1 and the SO pin; the SO path is gated by SOE
(VIA2 CA2 /
PCR=$EE). Seek/gap phases suppress it. - Stepping is decoded from the 4-phase Gray pattern, not a DOS target-track — fastloaders bypass the job queue.
- GCR layout must match VICE byte-for-byte (sync lengths,
$0F $0Fheader tail, per-zone gaps) or cycle-counted decoders reject it. - Two opposite zone numberings exist (
zoneForTrackvs. the VIA2 density bits) — don't conflate them. - The load trap (TDE off) bypasses DOS only at
$FFD5— it reads the D64 directly for standard KERNAL LOADs, but an attached drive 8 still remains live on the IEC bus for lower-level protocol traffic and bit-banged loaders. - Idle 1541 cycles are skipped, not free-run forever — only after the drive is in a recognized idle loop with bus/motor/LED/IRQ quiet; VIA timer time is batched and settled on wake.
- Mechanical timing is opt-in; spin-up, head-settle, and the SO delay all default off.