smloader - a minimalist ZX Spectrum custom tape loader
Loading games into the ZX Spectrum computer was an adventure in itself. For between 2-4.5 minutes the user was treated to a light show of red/cyan then blue/yellow lines dancing in the background - all to an audio score for which some time was required to acquire a taste.

This is, of course, if the loader being used was the default, ROM-located tape loader - invoked commonly from BASIC via LOAD "".

Some games, however, came with their own loader. This enabled any imaginable kind of on-screen effect - though limited by the timing of the analogue signal on the tape.

As a kid, I was fascinated by the magical-seeming loaders, which showed counters, drew the screen in interesting ways, etc.

Hewson games featured interesting custom loaders, such as the counter one here.

This drove me to discover how these worked. smloader was thus created. It is based on the ROM routines, but stripped down severely in size. smloader displays NOTHING while loading, and either jumps into the load target address (when loading is successful) or resets the computer via USR 0 (when loading fails).

Stripped down to only 161 bytes long, smloader can serve as a starting point for YOUR own ZX Spectrum custom loader.


smloader source complete with tools - unzip anywhere and run make.bat to build a TAP image showing how smloader can be used to load a game. Contains everything (source code, tools) you need to study and extend smloader with your own effects

example TAP containing program loaded with smloader - run in any ZX Spectrum emulator.
NOTE that smloader displays NOTHING while loading; keep the tape running despite no visual progress indicators
NOTE that many emulators which use "insta-loading" will likely automatically stop the tape immediately BEFORE the block to be loaded by smloader (last block in this TAP). This is because many emulators' way of telling whether the program is awaiting tape load is by matching the PC (Program Counter) to the "Tape Routines" address range in the ZX Spectrum ROM. As such, they are oblivious of custom loaders (obviously loaded at addresses other than the ROM).

Source code

For quick study, here is smloader's "load tape block" source code. For brevity, the call site is omitted.

The entire smloader assembles to a 161 byte binary.

; Loads a tape block as data (raw binary) to a specified location
; Requires interrupts be disabled
; input:
; IX - destination address
; DE - block length; returns error if mismatch
; output:
; CARRY - set when successful, clear when an error occurred
inc d ; reset the zero flag (D cannot have been 0xFF)
ex af, af' ; save entry flags
dec d ; restore D

in a, (0xFE) ;
and 0x40 ; read EAR in bit 6
ld c, a ; C := last signal value
cp a ; set the zero flag

ret nz ; we're done if there's an error

call detect_edge ; CARRY set if edge detected
jr nc, load_start ; no edge yet, so try again

ld hl, 0x0415 ; busy wait - outer loop counter
djnz delay_inner
dec hl ; outer loop counter--
ld a, h ;
or l ; if outer loop counter != 0
jr nz, delay_inner ; then run inner loop (256 iterations)
; here, HL = 0, B = 0
call detect_2_edges ; move up to an edge, to prepare to find lead-in
jr nc, ret_if_error ; no edge yet

; detect 256 edges of lead-in
ld b, 0x9C ; B := initial timer value
call detect_2_edges ; B := timer value when edge is found (if found)
jr nc, ret_if_error ; back to LD-BREAK if time-out

ld a, 0xC6 ; A := min. value B must reach for a lead-in edge
cp b ; if A >= B
jr nc, load_start ; then edges are too close together

inc h ; next edge
jr nz, lead_in ; if 256 edges not yet detected, detect next edge
; lead-in over, so now await 2 sync edges (here, H = 0)

ld b, 0xC9 ; B := initial timer value
call detect_edge ; B := timer value when edge is found (if found)
jr nc, ret_if_error ; back to LD-BREAK with time-out

ld a, b ;
cp 0xD4 ; if B >= 0xD4
jr nc, sync_edge_1 ; then try again (edges too far apart for sync)
call detect_edge ; find edge 2 of sync before B rises to 0xFF
ret nc ; if edge 2 not detected in time, then fail
; sync over, so now load data byte by byte (here, H = 0 from above)

ld b, 0xB0 ; timing
jr prepare_8_bits ; first iteration of load_1_byte won't store the
; byte, because it is the type flag byte
ex af, af' ; restore entry flags and type in A
jr nz, type_flag_byte ; we must await type flag byte

ld (ix+0), l ; place loaded byte at memory location
inc ix ; target memory pointer++
dec de ; bytes remaining--
jr load_byte_setup ; we're not dealing with a type flag byte

xor a ; set ZERO to stand-in for the removed type check

ex af, af' ; store the flags
ld b, 0xB2 ; timing

ld l, 1 ; byte is done when "marker" bit rotates into CARRY

call detect_2_edges ; B := how long we had to wait until second edge
ret nc ; if we didn't detect 2 edges then fail

ld a, 0xCB ; if 0xCB >= B (edges were closer than 0xCB)
cp b ; then tape bit was 0, else 1
rl l ; bit 0 of L := tape bit, CARRY := bit 7 of L
ld b, 0xB0 ; reset the B timer byte
jr nc, read_1_bit ; initial "marker" bit hasn't reached CARRY yet
; "marker" bit has reached CARRY, so we've read 8 bits from tape into L

ld a, h ;
xor l ;
ld h, a ; H := H XOR L (update parity with new byte)

ld a, d ;
or e ; if DE != 0
jr nz, load_1_byte ; then load another byte
; all bytes have been loaded
ld a, h ; prepare to check parity byte (must be 0)
cp 1 ; if (unsigned)H >= 1
ret ; then return error (CARRY is reset)

; Attempts to find two edges within the specified timeout
; Same input and output details as detect_edge below
call detect_edge ; call routine LD-EDGE-1 below
ret nc ; if timeout then return failure (CARRY reset)
; else flow below to find a second edge

; Attempts to find an edge within the specified timeout
; input:
; B - unsigned timeout, inverted: lower value represents a longer timeout
; C - byte containing bit of last signal value (high or low)
; output:
; CARRY - set an edge was found during the specified timeout, reset otherwise
; B - unsigned timeout value reached when successful
; C - on success, byte containing bit of current signal value
ld a, 24 ; pre-sample delay length
dec a ; delay counter--
jr nz, edge_delay ; busy wait

and a ; clear carry
inc b ; timeout counter++
ret z ; return with failure when B reaches 0 ("256")

nop ;
nop ; keep timing close to original after removals
in a, (0xFE) ; read EAR port 0xFE (bit 6 is EAR)
xor c ; compare with last signal value
and 0x40 ; keep bit 6
jr z, read_sample ; EAR bit hasn't changed, so no edge yet
; EAR bit has changed, so we have an edge
ld a, c ;
cpl ;
ld c, a ; last signal value := current signal value
scf ; indicate success (edge has been found)
ret ; return