ZX Spectrum Tape Blocks: TAP Framing, ROM Pulses, and Checksums
Inspect standard-speed ZX Spectrum tape blocks, validate their lengths and XOR checksums, and reconstruct the 48K ROM-loader pulse sequence.
A ZX Spectrum cassette is an analog waveform, not a row of bytes. The 48K machine’s service manual describes the ULA passing the cassette input from the EAR socket to the CPU through the tape interface, while SAVE writes through the MIC output. A loader measures transitions in that waveform and reconstructs data. Modern emulator files such as .tap sit at a higher level: they store blocks of decoded bytes, leaving standard-speed playback timing to the emulator.
That distinction matters when preserving a tape. A .tap block can be expanded into the familiar 48K ROM-loader pulse pattern, but it cannot preserve noise, speed variation, amplitude, unusual loaders, or every timing choice of the original recording. A waveform image or a richer format such as TZX is needed when those details matter.
The standard 48K ROM block
For the standard 16K/48K ROM loader, each ordinary block begins with a pilot, then two sync pulses, then encoded bytes. The conventional 48K timings are measured in Z80 T-states:
| Part | Header-style block | Data-style block | Pulse duration |
|---|---|---|---|
| Pilot | 8,063 pulses | 3,223 pulses | 2,168 T-states each |
| First sync | one | one | 667 T-states |
| Second sync | one | one | 735 T-states |
| Data bit 0 | two pulses | two pulses | 855 T-states each |
| Data bit 1 | two pulses | two pulses | 1,710 T-states each |
The flag byte selects the pilot class by its high bit in the standard ROM-loader implementation. Ordinary BASIC-style header blocks start with 0x00; ordinary data blocks start with 0xFF. Bits are sent most-significant-bit first within each byte, while bytes appear in their normal block order. A timing is therefore a pulse duration, not the full high-plus-low period of a square wave. The Spectrum’s analog input threshold and a real tape’s signal quality still affect what the ROM can recognize.
The ordinary checksum is an XOR across the flag, payload, and final checksum byte; the total must be zero. A standard file header has 17 content bytes between its flag and checksum, making a 19-byte block in all. Those content bytes include a file type, a ten-byte name, a length, and two parameters. The name uses Spectrum character codes, so blindly decoding it as ASCII can produce misleading text.
.tap is a block container, not a recording
In a standard TAP file, each block is preceded by a two-byte little-endian length. That length counts the following block bytes, including the flag and checksum. The next block begins immediately after those bytes. The TAP record does not contain the pilot pulse count, sync widths, analog amplitude, or pause duration as raw measurements. An emulator synthesizes the conventional ROM-loader sequence when it plays a standard block.
This small inspector validates file framing and the Spectrum XOR check before reporting a block. It rejects truncated lengths, incomplete data, and blocks too short to contain a flag plus checksum. It does not modify the image or try to execute a loader.
from pathlib import Path
def inspect_tap(data: bytes) -> None:
offset = 0
block_number = 0
while offset < len(data):
if len(data) - offset < 2:
raise ValueError(f"block {block_number}: truncated length prefix")
size = int.from_bytes(data[offset:offset + 2], "little")
offset += 2
if size < 2:
raise ValueError(f"block {block_number}: no flag/checksum pair")
if size > len(data) - offset:
raise ValueError(f"block {block_number}: length exceeds remaining file")
block = data[offset:offset + size]
offset += size
checksum = 0
for value in block:
checksum ^= value
if checksum != 0:
raise ValueError(f"block {block_number}: XOR checksum failed")
flag = block[0]
kind = "header" if flag == 0x00 else "data" if flag == 0xFF else "custom flag"
pilot_count = 8063 if (flag & 0x80) == 0 else 3223
print(f"{block_number}: {kind}, {size} bytes, {pilot_count} pilot pulses, checksum OK")
block_number += 1
inspect_tap(Path("game.tap").read_bytes())
The file-size field is untrusted input. Production tooling should place an explicit limit on the image size it will read and should keep its block cursor bounded as shown. This example assumes ordinary Spectrum TAP framing; other container formats have their own block headers and must not be parsed with the same length rule.
Reconstructing pulses without inventing waveform data
After a block passes its structural and XOR checks, a standard-speed pulse generator can emit the pilot and sync durations above, then process each byte from bit 7 down to bit 0. For each bit, emit two durations: (855, 855) for zero or (1710, 1710) for one. Use 8,063 pilot pulses when the flag high bit is clear and 3,223 when it is set. This reconstructs the nominal timing sequence modeled by the 48K ROM loader; it does not reconstruct the analog cassette waveform.
The pinned libspectrum implementation makes those layers visible in code. Its TAP reader consumes a little-endian length prefix, checks that the declared block fits in the remaining input, and creates a standard ROM tape block. Its tape-block initializer chooses the pilot pulse count from the first byte. Its edge generator emits the pilot, sync, and paired data timings. That is useful emulator behavior to study, but it is not a claim that every Spectrum model, clone, or custom loader uses the same constants.
For nonstandard loaders, do not force the recording into this standard model. A turbo loader can change pulse widths or bit order; some tapes rely on pure tones, pulse sequences, or pauses between data. TZX describes these richer block types, while a WAV or other sampled capture retains the waveform needed to analyze threshold crossings, dropouts, or wow and flutter. Preserve the original source image alongside any normalized TAP conversion, and record which layer was converted.
A practical preservation check
For a standard 48K ROM tape, an audit can report block number, length, flag, checksum result, and inferred pilot class. A failed length check indicates a truncated or incorrectly framed file. A failed XOR check indicates corruption or an incorrect block boundary, not automatically a bad physical cassette: conversion and editing tools can also damage bytes. A valid TAP checksum says the decoded bytes are internally consistent; it does not prove that the saved pulse timing matches the original cassette or that a program will load on every machine.
Keep three observations separate: the analog recording, the decoded block bytes, and the emulator’s generated pulse schedule. That separation makes preservation decisions reversible and helps distinguish physical signal loss from format conversion errors or model-specific loader behavior.
Related:
- ZX Spectrum 48K ULA Contention and Floating Bus: Fetch Phases and CPU Waits
- Commodore 1541 GCR: Sector Framing, 4-to-5 Encoding, and Track Zones
Sources: