Docs / Datasette (1530 / C2N) — Architecture
Datasette (1530 / C2N) — Architecture#
Source: src/datasette.js. Wired into the machine in
src/machine.js; see the master overview
and the machine orchestrator.
The Commodore 1530 Datasette plays back .tap recordings by replaying their
pulse train as edges on the CIA1 FLAG line — exactly the signal a real
datasette feeds the C64. The C64 has no tape controller: the KERNAL bit-bangs
tape I/O by timing FLAG interrupts, so the emulator only has to deliver
correctly-timed edges.
1. Signal path#
.tap pulses ──► Datasette.clock() ──► FLAG falling edge ──► CIA1 FLAG IRQ ──► KERNAL tape reader
▲ │
MOTOR ── CPU $01 bit 5 (low = run) SENSE ── CPU $01 bit 4 ◄── PLAY button
Three lines connect the deck to the machine:
- READ → CIA1 FLAG. Each tape pulse is delivered as a falling edge through
flagCallback, wired tocia1.setFlag(level)inmachine.js. Every edge can raise a CIA1 FLAG interrupt — the timing between edges is the data. - MOTOR ← CPU port
$01bit 5.memory.jscallssetMotor(!(cpuPort & 0x20)): the motor runs when bit 5 is driven low (and only while DDR bit 5 is an output; a floating input leaves the motor untouched). - SENSE → CPU port
$01bit 4.getSenseLevel()returns0when a key (PLAY) is down,1when no button / no tape is present. The KERNAL polls this to print "PRESS PLAY ON TAPE" and to know when to start reading.
2. TAP file format#
A 20-byte header followed by a stream of pulse-length bytes:
$00–$0B "C64-TAPE-RAW" magic
$0C version (0, 1, or 2)
$0D–$0F reserved
$10–$13 data size, little-endian
$14… pulse data
Each data byte encodes the cycles until the next edge:
- Byte N ≠ 0 →
N × 8cycles (so one byte covers up to 2040 cycles). - Byte 0:
- v0 → a fixed long pulse of 2048 cycles.
- v1 / v2 → the next three bytes are an exact 24-bit little-endian cycle count (for pulses longer than a single byte can express).
loadTap() validates the magic and version (≤ 2), slices out the data by the
header size, and pre-computes an estimated duration.
3. Playback engine#
State is a cursor into the pulse stream plus a down-counter to the next edge:
pos— byte offset intotapData.cyclesUntilEdge— cycles remaining before the next edge fires._loadNextPulse()— decodes the next byte(s) intocyclesUntilEdge; setsatEndwhen the stream is exhausted.
clock(cycles) runs each master cycle (only while motorOn && playPressed):
it subtracts the elapsed cycles and, whenever the counter reaches zero, emits an
edge and loads the next pulse (a while loop, so several short pulses can fire
within one tick). Edge shape depends on the TAP version:
- v0 / v1 — each pulse is a full wave: drive FLAG low then immediately high (a clean falling edge, which is what the KERNAL times).
- v2 — each pulse is a half-wave: the FLAG level simply toggles on every pulse (used by half-wave recordings that capture both edges).
4. Motor & the 300 ms startup window#
A real 1530 needs roughly 300 ms for the capstan to reach a stable speed; pulses
delivered before then would be mis-decoded. MOTOR_STARTUP_CYCLES
(≈ 0.30 s × 985248 Hz) models this: when the motor starts — or when PLAY is
pressed with the motor already running — a stabilization window is armed, and
clock() withholds edges until it elapses (consuming the remainder of the tick
that crosses the boundary so no cycles are lost).
5. Transport, SENSE, position & duration#
setPlayPressed()/setMotor()gate playback and re-arm the startup window; releasing PLAY or stopping the motor restores FLAG high (_restoreFlagHigh).rewind()resets the transport to the start while preserving the loaded media.positionFraction(by file offset) drives the UI progress bar;elapsedSecondsis derived from_cyclesTotal / 985248;durationSecondsis estimated at load time by summing the pulse lengths.eject()clears the media;reset()stops the motor but keeps the tape + PLAY state so the KERNAL can re-detect SENSE after a machine reset.
6. Machine integration & clocking#
The machine constructs one Datasette, wires flagCallback to CIA1, and clocks
it once per master cycle after the CPU step (so a pulse edge sets the CIA1
FLAG data for the next cycle — consistent with the phi1/phi2 ordering in the
master cycle). loadTap /
setTapePlayPressed / rewindTape / ejectTape are exposed as machine methods
driven by the UI transport buttons.
7. Save-state#
serialize() / deserialize() capture the transport position and motor/PLAY
state (pos, cyclesUntilEdge, motorOn, playPressed, atEnd, FLAG level,
counters). The TAP bytes are captured separately as bundled media by the
machine, and flagCallback is re-wired on restore — so deserialize() must run
after the TAP data has been re-attached via loadTap().
8. Limitations#
Playback only — there is no recording, tape write, or fast-forward path, and motor speed is idealised (only the startup window is modelled, not wow/flutter). See Known issues.