A Minimal C64 Datasette Program Loader

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.

    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.

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

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
    bne cx
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.

    sta count
    ldy #0
g1: jsr get_byte
    bcs g2
    sta (ptr),y
    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:

    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.

6 thoughts on “A Minimal C64 Datasette Program Loader”

  1. “These count cycles of the C64 base clock (roughly milliseconds)” – an off-by-one (order of magnitude) error? 🙂

  2. I wrote one of these back in 1984 and we sold it to several UK games companies as a tape mastering system which included a directory and speeds of 3,5 and 9 x normal tape speed. We sold 14 and it introduced our fledging firm (Choice Software) to publishers and we got a lot of conversion work from the likes of Mogul, Lothlorien, PSS, US Gold and Ocean.

    It used the interrupt generated when a pulse went from high to low on the tape input. It was then a matter of timing. This was done in a loop that incremented a counter. It was started on one interrupt and then the counting was stopped by the next interrupt. Something like 80-130 micro-seconds was a 0 bit and longer was a 1.

    • Very neat stuff! Personally, I’d like things to have remained as they were in the 1980s – a time when things made sense.


Leave a Comment