C64 READY.

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 CPU class 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):

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:

  $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

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

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.

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):

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):

  1. Bit clock: bitCycleAccum accumulates cycles; one bit is shifted every CYCLES_PER_BYTE[speedZone] / 8 cycles. 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.
  2. Track fetch: on a head move (trackDirty), pull the GCR stream for the current track from GCRDisk and rescale the bit position so rotation phase is preserved across the step.
  3. Bit shift: read the next bit from the track bitstream (which loops — the disk spins continuously), shift it into _shiftReg.
  4. 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.
  5. 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):

Decoding (write-back). The inverse path folds the mutated track buffer back into the image:

Note the two opposite zone numberings: zoneForTrack (outer→inner 0..3, used for TRACK_SIZE/TAIL_GAP) vs. the VIA2 PB5-6 density bits (used for CYCLES_PER_BYTE). The drive's speedZoneBitsForTrack uses the latter.


9. The D64 image (d64.js)

D64 parses a standard 35-track (683-sector) or extended 40-track image:


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)