The Commodore Datasette recording format is heavily optimized for data safety and can compensate for many typical issues of cassette tape, like incorrect speed, inconsistent speed (wow/flutter), and small as well as longer dropouts. This makes the format more complex and way less efficient than, for example, “Turbo Tape” or all other custom formats used by commercial games. Let’s explore the format by writing a minimal tape loader for the C64, optimized for size, which can decode correct tapes, but does not support error correction.
All information is encoded in the duration of pulses. There are three types of pulses: short (352 µs), medium (512 µs) and long (672 µs). To measure the lengths of the pulses, we can use one of the timers in the CIA chips: These count cycles of the C64 base clock (roughly microseconds), counting down from a a 16 bit start value. Since the pulse lengths are above 255, this would require us to compare 16 bit measurement results, but since microseconds are too high a resolution for tapes anyway, we can just as well only count units of 8 clock cycles – this matches the resolution of the “TAP” archival format.
This can be done by having timer A continuously count down from 7 to 0 (at the speed of the system clock) and having timer B count underruns of timer A:
lda #7 ;divide timer b by 8 ldx #0 sta $dd04 stx $dd05 lda #%00010001 sta $dd0e ;start timer a dex ; $ff stx $dd06 ;always start timer b stx $dd07 ;from $ffff
The lengths of the pulses in units of 8 PAL clock cycles are:
length_short = $30 length_medium = $42 length_long = $56
To differentiate the pulses, let’s take the arithmetic means as thresholds:
threshold_short_medium = (length_short + length_medium) / 2 threshold_medium_long = (length_medium + length_long) / 2
The subroutine get_pulse measures a pulse by reading the low byte of the value of timer B (which counts down from $ffff) and restarts the timer. The (negative) length is returned in A. It will also set the C flag if it was a short pulse. If C is clear, it was a medium or a long pulse.
get_pulse: lda #$10 p1: bit $dc0d ;wait for start bne p1 ;of pulse ldx #%01011001 ;value to restart timer b p2: bit $dc0d ;wait for end beq p2 ;of pulse lda $dd06 ;read timer b stx $dd0f ;restart timer b cmp #$ff-threshold_short_medium rts ;c=1: short
Bits and Bytes
Every bit is encoded using two pulses: short/medium is 0, and medium/short is 1:
S/M 0 M/S 1
The byte marker (long/medium) allows detecting byte boundaries:
L/M byte marker
So a byte is encoded with a byte marker, followed by the 8 bits (LSB first) and an (inverted) parity bit.
L/M S/M S/M S/M S/M S/M S/M S/M S/M M/S marker bit0 bit1 bit2 bit3 bit4 bit5 bit6 bit7 parity
There is one more pulse combination, the end of data marker (long/short), which indicates the end of a file:
L/S end of data
The code to read a byte first reads pulses until it finds either the byte marker of the end of data marker. In the case of end of data, it returns with the C flag set. If it found a byte marker, it will reads 8 bits, ignoring the length of the first pulse and only deciding whether it was a 0 or a 1 based on the length of the second pulse. The check bit is ignored: When reading the next byte, the code to search for the marker will just skip it.
get_byte: ; wait for byte marker jsr get_pulse cmp #$ff-threshold_medium_long bcs get_byte ;not long jsr get_pulse bcs b2 ;short = end of data ; get 8 bits lda #%01111111 b1: pha jsr get_pulse ;ignore first jsr get_pulse pla ror ;shift in bit bcs b1 ;until canary bit b2: rts
For code compactness, this loop of 8 iterations stores a canary bit with a value of 0 at position 7 of the result during initialization. With every read bit that is rotated into the result, the canary bit is shifted to the right, and after 8 iterations, it is shifted into the carry flag. This way, we can detect we’re done with all 8 bits without using a counter.
Leader and Countdown
The encoding described so far allows a cassette tape to store an arbitrary stream of bytes, with a marker to indicate the end, but no way to know where a file starts. Therefore, the data of every file is prefixed by the leader and the countdown.
The leader is a sequence of less than a second to up to ten seconds consisting entirely of short pulses. It allows the reader to adjust the expected pulse lengths to compensate for different motor speeds. In our minimal reader, we can just ignore it.
The countdown is a sequence of the hex bytes $89, $88, $87, … down to $81. This is the code to detect it:
get_countdown: c0: jsr get_byte c1: ldy #$89 sty tmp_cntdwn ;start with $89 ldy #9 bne c2 cx: jsr get_byte c2: cmp tmp_cntdwn bne c4 dec tmp_cntdwn dey bne cx rts c4: cpy #9 ;first byte wrong? beq c0 ;then read new byte bne c1 ;compare against $89
This code may look more complicated than it needs to be, but the extra complexity comes from the edge case of an incomplete countdown immediately followed by a complete countdown (e.g. $89, $88, $89, $88, $87, …, $81).
After the countdown, the subroutine get_block will read the number of bytes passed in A into memory pointed to by ptr.
get_block: sta count ldy #0 g1: jsr get_byte bcs g2 sta (ptr),y iny dec count bne g1 g2: rts
If it encounters an end of data marker, the subroutine will return prematurely with the C flag set.
The encoding described so far allows storing individual files, but there is no metadata yet. So for every file on tape, there is a 192 byte header which precedes it.
The header encodes the filename, its type, and in case of programs, the start and end addresses.
Offset Length Description 0 1 File Type ($01 or $03 for PRG) 1 2 Start Address 3 2 End Address 5 16 Filename 21 171 unused
There are other file types to support SEQ files, which are stored a sequences of individual 192 byte files.
The header itself encoded exactly like a 192 byte file and recorded just before the file data: with a leader and a countdown.
The KERNAL’s save routines will actually store a backup copy of both the header and the full file contents immediately after the original to mitigate tape dropouts, with the countdowns using the sequence $09, $08, $07, … down to $01. Our code assumes an error-less tape, so we ignore the copies.
Connecting everything together
Our minimal loader makes several assumptions: The tape must be error-less and rewound, and the first file must be a program, correctly preceded by a header.
The user has to press the PLAY button, so we have to wait until this has happened:
lda #$10 m1: bit $01 bne m1
Timing is critical when reading from tape, so we have to turn off interrupts and disable the screen to make sure the VIC doesn’t steal cycles:
sei lda $d011 and #$ff-$10 ;disable screen sta $d011 m2: lda $d012 bne m2 ;wait for new screen
Now we turn on the Datasette motor:
lda $01 and #$ff-$20 ;motor on sta $01
Now we can read the header by calling get_block with a byte count of 192:
ldx #<buffer ldy #>buffer stx ptr sty ptr + 1 jsr get_countdown lda #192 jsr get_block
We ignore most fields of the header and only read the start address to know where to load the file:
ldx buffer + 1 ldy buffer + 2 stx ptr sty ptr + 1
Now we can load the file:
m3: lda #0 jsr get_block inc ptr + 1 bcc m3
Once we’re done, we turn the motor off and the screen back on:
lda $d011 ora #$10 sta $d011 ;screen on lda 1 ora #$20 ;motor off sta 1
This minimal tape loader takes up less than 200 bytes. It might seem useless for any other purpose than learning about the Datasette recoding format, since the C64 KERNAL already contains code like this. But it is common to create modified versions of the KERNAL that add features, removing the original tape code to create some space, so this minimal loader could be added to such a modified KERNAL to save on space but still be able to load games from tape.
The complete source is available at github.com/mist64/datasette_load. bugfixes and size optimizations are very much appreciated.