█▓▒░C64 READY.

Docs / 1541 Disk Drive (src/drive1541.js + src/gcr.js + src/media/d64.js + src/media/g64.js + src/6522.js): Architecture Overview

1541 Disk Drive (src/drive1541.js + src/gcr.js + src/media/d64.js + src/media/g64.js + src/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, the raw G64 track source, 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)
media/d64.js D64: parses a D64, D71 or D81 sector image by its layout: 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 raised this cycle is visible to the CPU's micro-op when it samples them; with the order reversed, the DOS's BVS/IRQ-poll loops see events a cycle late and reads fail. The machine clocks an attached drive through a 16.16 drive:C64 accumulator at the true PAL ratio of 1 MHz / 985248 Hz.

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[zone] / 8 cycles. The four speed zones ([32,30,28,26] cycles/byte) model the constant-angular-velocity zones; outer tracks pack more bits. Which zone applies depends on the disk source: a D64 track is synthesized for whatever the VIA2 PB5-6 density bits select (currentSpeedZone), while a G64 track is clocked at the zone it was recorded in (_streamZone, from the image's speed table): the disk turns at 300 rpm whatever the drive selects, so recorded bits pass the head at the rate they were written. _readCell is the read circuit: its clock runs at the selected density and restarts at every transition, so a track read at another density yields a different count of zeros between ones (the elapsed time in selected cells, rounded, less one). 18 µs without a transition starts random ones every 2-25 µs, VICE's weak-bit rule (drive/rotation.c), until the next real one. A G64 per-byte speed map (_streamMap) moves _streamZone byte by byte.
  2. Track fetch: on a head move (trackDirty), pull the GCR stream for the current position from the disk source, getTrackStream(track, halfTrack), and rescale the bit position so rotation phase is preserved across the step. A D64 source (GCRDisk) has whole tracks only; a G64 records every half-track separately, so a head parked on an odd half-track reads that entry. A position with nothing recorded (an empty G64 entry, or past the last track of a D64) yields no stream: no SYNC, no bytes, the read side held reset, so the DOS's sync wait times out (error 21) rather than seeing the previous track's SYNC linger.
  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. Sector images: D64, D71 and D81 (media/d64.js)

D64 parses a 35-track (683-sector) image, an extended variant, or a 1581's D81, or a 1571's 70-track D71. A layout per kind says where the DOS keeps things: the 1541's header and BAM share 18/0 (4-byte entries) with the directory from 18/1; the 1581 has its header at 40/0, forty tracks per BAM sector at 40/1 and 40/2 (6-byte entries: count plus a 40-bit map) and its directory from 40/3, interleave 1. _bamEntry, _allocateBlocks and createBlankDisk(kind) all go through the layout. A D81 is readableBy1541: false: Drive1541.setDisk() treats it as an empty drive, and only the load trap serves it.

D71 repeats the 35-track geometry on side two. Its header/directory remain 18/0 and 18/1. Tracks 36-70 store free counts in 18/0 at $DD and bitmaps in 53/0, so _bamEntry supplies separate count and map locations. Allocation reserves tracks 18 and 53; a blank disk has 1328 file blocks. D71 is virtual-only (readableBy1541: false); mounting disables TDE for the selected device.

The G64 image (media/g64.js)

A .g64 stores what the read head sees rather than sectors: one raw GCR bitstream per half-track, at its recorded length, plus the speed zone each was written in. Drive1541.setDisk() takes either kind of image and keeps two references: disk (the image, for dirty and writeProtected) and gcrDisk (the GCR source: the G64 itself, or a GCRDisk wrapping a D64). Both sources offer the same interface: getTrackStream(track, halfTrack), markTrackDirty(track, halfTrack), hasDirtyTracks(), commitDirtyTracks() and, on a G64, speedZoneFor(halfTrack).

Nibbler dumps (media/nib.js)

A .nib holds 8 KB straight off the read head per half-track, more than one revolution, begun anywhere; a .nbz is the same file as one LZ77 stream. nibFileToG64() inflates it and, per half-track, extractTrackCycle() finds where the data comes round (matching what follows each sync, else any repeating 7 bytes, within the zone's capacity range), starts the revolution at the tail gap, else at sector 0, else at the longest run, and nibToG64() spreads a fat track to the half-track between, zeroes the inside of bad-GCR runs and shortens sync runs on any track a real disk could not hold. The rules and constants are nibconv's at its defaults (see NOTICE.txt); the output is an ordinary G64 for G64 and the drive.


The virtual drive (src/media/virtual-drive.js)

With true drive emulation off, the trap-served drive is a VirtualDrive: a DOS over the mounted sector image (D64, D71 or D81), answering the KERNAL's serial primitives instead of the IEC bus. The machine traps TALK and LISTEN ($ED09, $ED0C) when A names a trap-served device, then SECOND, TKSA, CIOUT, ACPTR, UNTALK and UNLISTEN until the drive is released, and returns from each as the ROM would. A stock KERNAL is required.

Area Supported Not supported
Open [@][0:]name[,P|S|U][,R|W|A], wildcards, $[0:][pattern] as the LOAD"$" listing, # buffer; sa 0 reads and sa 1 writes a PRG REL files (,L), partitions (64), more than one drive number
Read the file with EOI on the last byte, then a timeout
Write collected and written at close as PRG, SEQ or USR; @ replaces, ,A appends writes larger than the free space fail at close (72), not as they arrive
Channel 15 I, V, UI/UJ (73), S, R, N, U1/U2, B-R/B-W, B-P, M-R (zero bytes), M-W/M-E (accepted, no effect) C, D, P, REL positioning, drive code (31)
Status NN,MESSAGE,TT,SS: 00, 01, 26, 31, 62, 63, 64, 66, 70, 72, 73 (1541 or 1581 wording by image kind), 74 read errors from an error table
Hardware bus timing, LED on the bus, loaders that bit-bang $DD00 (they still find the real 1541, or nothing)

Hooks: onOpen drives the LED and drive sound, onWrite the app's directory refresh and Library save. Channels are transient: a disk swap or reset closes them, and a save state holds none.

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). The deferred VIA time is batched and settled (settleIdleCycles) when a bus change arrives or a timed wake fires (idleSkipWakeCycles, derived from the VIA timers). 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 are compile-time constants at the top of drive1541.js (not switches.js entries) and 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)