diff --git a/README.md b/README.md
index 4bb4639b9..2e7899a76 100644
--- a/README.md
+++ b/README.md
@@ -141,12 +141,14 @@ pssh_playready,
[rtmp](doc/formats.md#rtmp),
sll2_packet,
sll_packet,
+[tap](doc/formats.md#tap),
tar,
tcp_segment,
tiff,
[tls](doc/formats.md#tls),
toml,
[tzif](doc/formats.md#tzif),
+[tzx](doc/formats.md#tzx),
udp_datagram,
vorbis_comment,
vorbis_packet,
diff --git a/doc/formats.md b/doc/formats.md
index 448dab81d..80b7aea4b 100644
--- a/doc/formats.md
+++ b/doc/formats.md
@@ -113,12 +113,14 @@
|[`rtmp`](#rtmp) |Real-Time Messaging Protocol |`amf0` `mpeg_asc`|
|`sll2_packet` |Linux cooked capture encapsulation v2 |`inet_packet`|
|`sll_packet` |Linux cooked capture encapsulation |`inet_packet`|
+|[`tap`](#tap) |TAP tape format for ZX Spectrum computers ||
|`tar` |Tar archive |`probe`|
|`tcp_segment` |Transmission control protocol segment ||
|`tiff` |Tag Image File Format |`icc_profile`|
|[`tls`](#tls) |Transport layer security |`asn1_ber`|
|`toml` |Tom's Obvious, Minimal Language ||
|[`tzif`](#tzif) |Time Zone Information Format ||
+|[`tzx`](#tzx) |TZX tape format for ZX Spectrum computers |`tap`|
|`udp_datagram` |User datagram protocol |`udp_payload`|
|`vorbis_comment` |Vorbis comment |`flac_picture`|
|`vorbis_packet` |Vorbis packet |`vorbis_comment`|
@@ -137,7 +139,7 @@
|`ip_packet` |Group |`icmp` `icmpv6` `tcp_segment` `udp_datagram`|
|`link_frame` |Group |`bsd_loopback_frame` `ether8023_frame` `ipv4_packet` `ipv6_packet` `sll2_packet` `sll_packet`|
|`mp3_frame_tags` |Group |`mp3_frame_vbri` `mp3_frame_xing`|
-|`probe` |Group |`adts` `aiff` `apple_bookmark` `ar` `avi` `avro_ocf` `bitcoin_blkdat` `bplist` `bzip2` `caff` `elf` `fit` `flac` `gif` `gzip` `html` `jp2c` `jpeg` `json` `jsonl` `leveldb_table` `luajit` `macho` `macho_fat` `matroska` `moc3` `mp3` `mp4` `mpeg_ts` `nes` `ogg` `opentimestamps` `pcap` `pcapng` `png` `tar` `tiff` `toml` `tzif` `wasm` `wav` `webp` `xml` `yaml` `zip`|
+|`probe` |Group |`adts` `aiff` `apple_bookmark` `ar` `avi` `avro_ocf` `bitcoin_blkdat` `bplist` `bzip2` `caff` `elf` `fit` `flac` `gif` `gzip` `html` `jp2c` `jpeg` `json` `jsonl` `leveldb_table` `luajit` `macho` `macho_fat` `matroska` `moc3` `mp3` `mp4` `mpeg_ts` `nes` `ogg` `opentimestamps` `pcap` `pcapng` `png` `tar` `tiff` `toml` `tzif` `tzx` `wasm` `wav` `webp` `xml` `yaml` `zip`|
|`tcp_stream` |Group |`dns_tcp` `rtmp` `tls`|
|`udp_payload` |Group |`dns`|
@@ -1209,6 +1211,26 @@ fq '.tcp_connections[] | select(.server.port=="rtmp") | d' file.cap
- https://rtmp.veriskope.com/docs/spec/
- https://rtmp.veriskope.com/pdf/video_file_format_spec_v10.pdf
+## tap
+TAP tape format for ZX Spectrum computers.
+
+The TAP- (and BLK-) format is nearly a direct copy of the data that is stored
+in real tapes, as it is written by the ROM save routine of the ZX-Spectrum.
+A TAP file is simply one data block or a group of 2 or more data blocks, one
+followed after the other. The TAP file may be empty.
+
+You will often find this format embedded inside the TZX tape format.
+
+The default file extension is `.tap`.
+
+### Authors
+
+- Michael R. Cook work.mrc@pm.me, original author
+
+### References
+
+- https://worldofspectrum.net/zx-modules/fileformats/tapformat.html
+
## tls
Transport layer security.
@@ -1378,6 +1400,27 @@ fq '.v2plusdatablock.leap_second_records | length' tziffile
### References
- https://datatracker.ietf.org/doc/html/rfc8536
+## tzx
+TZX tape format for ZX Spectrum computers.
+
+`TZX` is a file format designed to preserve cassette tapes compatible with the
+ZX Spectrum computers, although some specialized versions of the format have
+been defined for other machines such as the Amstrad CPC and C64.
+
+The format was originally created by Tomaz Kac, who was maintainer until
+`revision 1.13`, before passing it to Martijn v.d. Heide. For a brief period
+the company Ramsoft became the maintainers, and created revision `v1.20`.
+
+The default file extension is `.tzx`.
+
+### Authors
+
+- Michael R. Cook work.mrc@pm.me, original author
+
+### References
+
+- https://worldofspectrum.net/TZXformat.html
+
## wasm
WebAssembly Binary Format.
diff --git a/format/all/all.fqtest b/format/all/all.fqtest
index 4c3b2f86e..7dea22d8b 100644
--- a/format/all/all.fqtest
+++ b/format/all/all.fqtest
@@ -32,6 +32,7 @@ $ fq -n _registry.groups.probe
"tar",
"tiff",
"tzif",
+ "tzx",
"wasm",
"webp",
"zip",
@@ -156,12 +157,14 @@ pssh_playready PlayReady PSSH
rtmp Real-Time Messaging Protocol
sll2_packet Linux cooked capture encapsulation v2
sll_packet Linux cooked capture encapsulation
+tap TAP tape format for ZX Spectrum computers
tar Tar archive
tcp_segment Transmission control protocol segment
tiff Tag Image File Format
tls Transport layer security
toml Tom's Obvious, Minimal Language
tzif Time Zone Information Format
+tzx TZX tape format for ZX Spectrum computers
udp_datagram User datagram protocol
vorbis_comment Vorbis comment
vorbis_packet Vorbis packet
diff --git a/format/all/all.go b/format/all/all.go
index 882b46a6c..865c62128 100644
--- a/format/all/all.go
+++ b/format/all/all.go
@@ -52,12 +52,14 @@ import (
_ "github.com/wader/fq/format/protobuf"
_ "github.com/wader/fq/format/riff"
_ "github.com/wader/fq/format/rtmp"
+ _ "github.com/wader/fq/format/tap"
_ "github.com/wader/fq/format/tar"
_ "github.com/wader/fq/format/text"
_ "github.com/wader/fq/format/tiff"
_ "github.com/wader/fq/format/tls"
_ "github.com/wader/fq/format/toml"
_ "github.com/wader/fq/format/tzif"
+ _ "github.com/wader/fq/format/tzx"
_ "github.com/wader/fq/format/vorbis"
_ "github.com/wader/fq/format/vpx"
_ "github.com/wader/fq/format/wasm"
diff --git a/format/format.go b/format/format.go
index 41adaa68a..a87ff0e02 100644
--- a/format/format.go
+++ b/format/format.go
@@ -165,12 +165,14 @@ var (
RTMP = &decode.Group{Name: "rtmp"}
SLL_Packet = &decode.Group{Name: "sll_packet"}
SLL2_Packet = &decode.Group{Name: "sll2_packet"}
+ TAP = &decode.Group{Name: "tap"}
TAR = &decode.Group{Name: "tar"}
TCP_Segment = &decode.Group{Name: "tcp_segment"}
TIFF = &decode.Group{Name: "tiff"}
TLS = &decode.Group{Name: "tls"}
TOML = &decode.Group{Name: "toml"}
Tzif = &decode.Group{Name: "tzif"}
+ TZX = &decode.Group{Name: "tzx"}
UDP_Datagram = &decode.Group{Name: "udp_datagram"}
Vorbis_Comment = &decode.Group{Name: "vorbis_comment"}
Vorbis_Packet = &decode.Group{Name: "vorbis_packet"}
diff --git a/format/tap/tap.go b/format/tap/tap.go
new file mode 100644
index 000000000..b1e0c18f8
--- /dev/null
+++ b/format/tap/tap.go
@@ -0,0 +1,158 @@
+package tzx
+
+// https://worldofspectrum.net/zx-modules/fileformats/tapformat.html
+
+import (
+ "bufio"
+ "bytes"
+ "embed"
+
+ "golang.org/x/text/encoding/charmap"
+
+ "github.com/wader/fq/format"
+ "github.com/wader/fq/pkg/bitio"
+ "github.com/wader/fq/pkg/decode"
+ "github.com/wader/fq/pkg/interp"
+ "github.com/wader/fq/pkg/scalar"
+)
+
+//go:embed tap.md
+var tapFS embed.FS
+
+func init() {
+ interp.RegisterFormat(
+ format.TAP,
+ &decode.Format{
+ Description: "TAP tape format for ZX Spectrum computers",
+ DecodeFn: tapDecoder,
+ })
+
+ interp.RegisterFS(tapFS)
+}
+
+// The TAP- (and BLK-) format is nearly a direct copy of the data that is stored
+// in real tapes, as it is written by the ROM save routine of the ZX-Spectrum.
+// A TAP file is simply one data block or a group of 2 or more data blocks, one
+// followed after the other. The TAP file may be empty.
+func tapDecoder(d *decode.D) any {
+ d.Endian = decode.LittleEndian
+
+ d.FieldArray("blocks", func(d *decode.D) {
+ for !d.End() {
+ d.FieldStruct("block", func(d *decode.D) {
+ decodeTapBlock(d)
+ })
+ }
+ })
+ return nil
+}
+
+func decodeTapBlock(d *decode.D) {
+ // Length of the following data.
+ length := d.FieldU16("length")
+
+ // read header, fragment, or data block
+ switch length {
+ case 0:
+ // fragment with no data
+ case 1:
+ d.FieldRawLen("data", 8)
+ case 19:
+ d.FieldStruct("header", func(d *decode.D) {
+ decodeHeader(d)
+ })
+ default:
+ d.FieldStruct("data", func(d *decode.D) {
+ decodeDataBlock(d, length)
+ })
+ }
+}
+
+// decodes the different types of 19-byte header blocks.
+func decodeHeader(d *decode.D) {
+ blockStartPosition := d.Pos()
+
+ // Always 0: byte indicating a standard ROM loading header
+ d.FieldU8("flag", scalar.UintMapSymStr{0: "standard_speed_data"})
+ // Header type
+ dataType := d.FieldU8("data_type", scalar.UintMapSymStr{
+ 0x00: "program",
+ 0x01: "numeric",
+ 0x02: "alphanumeric",
+ 0x03: "data",
+ })
+ // Loading name of the program. Filled with spaces (0x20) to 10 characters.
+ d.FieldStr("program_name", 10, charmap.ISO8859_1)
+
+ switch dataType {
+ case 0:
+ // Length of data following the header = length of BASIC program + variables.
+ d.FieldU16("data_length")
+ // LINE parameter of SAVE command. Value 32768 means "no auto-loading".
+ // 0..9999 are valid line numbers.
+ d.FieldU16("auto_start_line")
+ // Length of BASIC program;
+ // remaining bytes ([data length] - [program length]) = offset of variables.
+ d.FieldU16("program_length")
+ case 1:
+ // Length of data following the header = length of number array * 5 + 3.
+ d.FieldU16("data_length")
+ // Unused byte.
+ d.FieldU8("unused0")
+ // (1..26 meaning A..Z) + 128.
+ d.FieldU8("variable_name", scalar.UintHex)
+ // UnusedWord: 32768.
+ d.FieldU16("unused1")
+ case 2:
+ // Length of data following the header = length of string array + 3.
+ d.FieldU16("data_length")
+ // Unused byte.
+ d.FieldU8("unused0")
+ // (1..26 meaning A$..Z$) + 192.
+ d.FieldU8("variable_name", scalar.UintHex)
+ // UnusedWord: 32768.
+ d.FieldU16("unused1")
+ case 3:
+ // Length of data following the header, in case of a SCREEN$ header = 6912.
+ d.FieldU16("data_length")
+ // In case of a SCREEN$ header = 16384.
+ d.FieldU16("start_address", scalar.UintHex)
+ // UnusedWord: 32768.
+ d.FieldU16("unused")
+ default:
+ d.Fatalf("invalid TAP header type, got: %d", dataType)
+ }
+
+ // Simply all bytes XORed (including flag byte).
+ d.FieldU8("checksum", d.UintValidate(calculateChecksum(d, blockStartPosition, d.Pos()-blockStartPosition)), scalar.UintHex)
+}
+
+func decodeDataBlock(d *decode.D, length uint64) {
+ blockStartPosition := d.Pos()
+
+ // flag indicating the type of data block, usually 255 (standard speed data)
+ d.FieldU8("flag", scalar.UintFn(func(s scalar.Uint) (scalar.Uint, error) {
+ if s.Actual == 0xFF {
+ s.Sym = "standard_speed_data"
+ } else {
+ s.Sym = "custom_data_block"
+ }
+ return s, nil
+ }))
+ // The essential data: length minus the flag/checksum bytes (may be empty)
+ d.FieldRawLen("data", int64(length-2)*8)
+ // Simply all bytes (including flag byte) XORed
+ d.FieldU8("checksum", d.UintValidate(calculateChecksum(d, blockStartPosition, d.Pos()-blockStartPosition)), scalar.UintHex)
+}
+
+func calculateChecksum(d *decode.D, blockStartPos, blockEndPos int64) uint64 {
+ var blockSlice bytes.Buffer
+ writer := bufio.NewWriter(&blockSlice)
+ d.Copy(writer, bitio.NewIOReader(d.BitBufRange(blockStartPos, blockEndPos)))
+
+ var checksum uint8
+ for _, v := range blockSlice.Bytes() {
+ checksum ^= v
+ }
+ return uint64(checksum)
+}
diff --git a/format/tap/tap.md b/format/tap/tap.md
new file mode 100644
index 000000000..c246fc4c1
--- /dev/null
+++ b/format/tap/tap.md
@@ -0,0 +1,16 @@
+The TAP- (and BLK-) format is nearly a direct copy of the data that is stored
+in real tapes, as it is written by the ROM save routine of the ZX-Spectrum.
+A TAP file is simply one data block or a group of 2 or more data blocks, one
+followed after the other. The TAP file may be empty.
+
+You will often find this format embedded inside the TZX tape format.
+
+The default file extension is `.tap`.
+
+### Authors
+
+- Michael R. Cook work.mrc@pm.me, original author
+
+### References
+
+- https://worldofspectrum.net/zx-modules/fileformats/tapformat.html
diff --git a/format/tap/testdata/README.md b/format/tap/testdata/README.md
new file mode 100644
index 000000000..4b8b3d769
--- /dev/null
+++ b/format/tap/testdata/README.md
@@ -0,0 +1,22 @@
+### basic_prog1.tap
+
+The `basic_prog1.tap` test file was created directory from the FUSE emulator.
+
+Inside the emulated ZX Spectrum a BASIC program was created:
+
+```
+10 PRINT "fq is the best!"
+20 GOTO 10
+```
+
+and saved to tape:
+
+```
+SAVE "fqTestProg", LINE 10
+```
+
+Then from FUSE select the menu item `Media > Tape > Save As..`.
+
+Any BASIC, machine code, screen image, or other data, can be saved directly
+using the `SAVE` command. Further instructions can be found here:
+https://worldofspectrum.org/ZXBasicManual/zxmanchap20.html
diff --git a/format/tap/testdata/basic_prog1.fqtest b/format/tap/testdata/basic_prog1.fqtest
new file mode 100644
index 000000000..19b51e22f
--- /dev/null
+++ b/format/tap/testdata/basic_prog1.fqtest
@@ -0,0 +1,21 @@
+$ fq -d tap dv basic_prog1.tap
+ |00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f|0123456789abcdef|.{}: basic_prog1.tap (tap) 0x0-0x3f (63)
+ | | | blocks[0:2]: 0x0-0x3f (63)
+ | | | [0]{}: block 0x0-0x15 (21)
+0x00|13 00 |.. | length: 19 0x0-0x2 (2)
+ | | | header{}: 0x2-0x15 (19)
+0x00| 00 | . | flag: "standard_speed_data" (0) 0x2-0x3 (1)
+0x00| 00 | . | data_type: "program" (0) 0x3-0x4 (1)
+0x00| 66 71 54 65 73 74 50 72 6f 67 | fqTestProg | program_name: "fqTestProg" 0x4-0xe (10)
+0x00| 26 00| &.| data_length: 38 0xe-0x10 (2)
+0x10|0a 00 |.. | auto_start_line: 10 0x10-0x12 (2)
+0x10| 26 00 | &. | program_length: 38 0x12-0x14 (2)
+0x10| 01 | . | checksum: 0x1 (valid) 0x14-0x15 (1)
+ | | | [1]{}: block 0x15-0x3f (42)
+0x10| 28 00 | (. | length: 40 0x15-0x17 (2)
+ | | | data{}: 0x17-0x3f (40)
+0x10| ff | . | flag: "standard_speed_data" (255) 0x17-0x18 (1)
+0x10| 00 0a 14 00 20 f5 22 66| .... ."f| data: raw bits 0x18-0x3e (38)
+0x20|71 20 69 73 20 74 68 65 20 62 65 73 74 21 22 0d|q is the best!".|
+0x30|00 14 0a 00 ec 31 30 0e 00 00 0a 00 00 0d |.....10....... |
+0x30| b6| | .|| checksum: 0xb6 (valid) 0x3e-0x3f (1)
diff --git a/format/tap/testdata/basic_prog1.tap b/format/tap/testdata/basic_prog1.tap
new file mode 100644
index 000000000..585d7801f
Binary files /dev/null and b/format/tap/testdata/basic_prog1.tap differ
diff --git a/format/tzx/testdata/README.md b/format/tzx/testdata/README.md
new file mode 100644
index 000000000..a2826acef
--- /dev/null
+++ b/format/tzx/testdata/README.md
@@ -0,0 +1,28 @@
+### basic_prog1.tzx
+
+The `basic_prog1.tzx` test file was created directory from the FUSE emulator.
+
+Inside the emulated ZX Spectrum a BASIC program was created:
+
+```
+10 PRINT "fq is the best!"
+20 GOTO 10
+```
+
+and saved to tape:
+
+```
+SAVE "fqTestProg", LINE 10
+```
+
+Then from FUSE select the menu item `Media > Tape > Save As..`.
+
+Any BASIC, machine code, screen image, or other data, can be saved directly
+using the `SAVE` command. Further instructions can be found here:
+https://worldofspectrum.org/ZXBasicManual/zxmanchap20.html
+
+
+#### Archive Info
+
+The FUSE emulator is not able to add the tape metadata. As this tape block is
+very simple, it was added manually using a Hex editor.
diff --git a/format/tzx/testdata/basic_prog1.fqtest b/format/tzx/testdata/basic_prog1.fqtest
new file mode 100644
index 000000000..d9b950d33
--- /dev/null
+++ b/format/tzx/testdata/basic_prog1.fqtest
@@ -0,0 +1,81 @@
+$ fq -d tzx dv basic_prog1.tzx
+ |00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f|0123456789abcdef|.{}: basic_prog1.tzx (tzx) 0x0-0xcd (205)
+0x00|5a 58 54 61 70 65 21 1a |ZXTape!. | signature: raw bits (valid) 0x0-0x8 (8)
+0x00| 01 | . | major_version: 1 0x8-0x9 (1)
+0x00| 14 | . | minor_version: 20 0x9-0xa (1)
+ | | | blocks[0:3]: 0xa-0xcd (195)
+ | | | [0]{}: block 0xa-0x88 (126)
+0x00| 32 | 2 | type: "archive_info" (50) 0xa-0xb (1)
+0x00| 7b 00 | {. | length: 123 0xb-0xd (2)
+0x00| 09 | . | count: 9 0xd-0xe (1)
+ | | | archive_info[0:9]: 0xe-0x88 (122)
+ | | | [0]{}: entry 0xe-0x1a (12)
+0x00| 00 | . | id: "title" (0) 0xe-0xf (1)
+0x00| 0a| .| length: 10 0xf-0x10 (1)
+0x10|66 71 74 65 73 74 70 72 6f 67 |fqtestprog | value: "fqtestprog" 0x10-0x1a (10)
+ | | | [1]{}: entry 0x1a-0x21 (7)
+0x10| 01 | . | id: "publisher" (1) 0x1a-0x1b (1)
+0x10| 05 | . | length: 5 0x1b-0x1c (1)
+0x10| 77 61 64 65| wade| value: "wader" 0x1c-0x21 (5)
+0x20|72 |r |
+ | | | [2]{}: entry 0x21-0x32 (17)
+0x20| 02 | . | id: "author" (2) 0x21-0x22 (1)
+0x20| 0f | . | length: 15 0x22-0x23 (1)
+0x20| 4d 69 63 68 61 65 6c 20 52 2e 20 43 6f| Michael R. Co| value: "Michael R. Cook" 0x23-0x32 (15)
+0x30|6f 6b |ok |
+ | | | [3]{}: entry 0x32-0x38 (6)
+0x30| 03 | . | id: "year" (3) 0x32-0x33 (1)
+0x30| 04 | . | length: 4 0x33-0x34 (1)
+0x30| 32 30 32 34 | 2024 | value: "2024" 0x34-0x38 (4)
+ | | | [4]{}: entry 0x38-0x41 (9)
+0x30| 04 | . | id: "language" (4) 0x38-0x39 (1)
+0x30| 07 | . | length: 7 0x39-0x3a (1)
+0x30| 45 6e 67 6c 69 73| Englis| value: "English" 0x3a-0x41 (7)
+0x40|68 |h |
+ | | | [5]{}: entry 0x41-0x4f (14)
+0x40| 05 | . | id: "category" (5) 0x41-0x42 (1)
+0x40| 0c | . | length: 12 0x42-0x43 (1)
+0x40| 54 65 73 74 20 50 72 6f 67 72 61 6d | Test Program | value: "Test Program" 0x43-0x4f (12)
+ | | | [6]{}: entry 0x4f-0x5c (13)
+0x40| 07| .| id: "loader" (7) 0x4f-0x50 (1)
+0x50|0b |. | length: 11 0x50-0x51 (1)
+0x50| 52 4f 4d 20 74 69 6d 69 6e 67 73 | ROM timings | value: "ROM timings" 0x51-0x5c (11)
+ | | | [7]{}: entry 0x5c-0x6e (18)
+0x50| 08 | . | id: "origin" (8) 0x5c-0x5d (1)
+0x50| 10 | . | length: 16 0x5d-0x5e (1)
+0x50| 4f 72| Or| value: "Original release" 0x5e-0x6e (16)
+0x60|69 67 69 6e 61 6c 20 72 65 6c 65 61 73 65 |iginal release |
+ | | | [8]{}: entry 0x6e-0x88 (26)
+0x60| ff | . | id: "comment" (255) 0x6e-0x6f (1)
+0x60| 18| .| length: 24 0x6f-0x70 (1)
+0x70|54 5a 58 65 64 20 62 79 20 4d 69 63 68 61 65 6c|TZXed by Michael| value: "TZXed by Michael R. Cook" 0x70-0x88 (24)
+0x80|20 52 2e 20 43 6f 6f 6b | R. Cook |
+ | | | [1]{}: block 0x88-0xa0 (24)
+0x80| 10 | . | type: "standard_speed_data" (16) 0x88-0x89 (1)
+0x80| e8 03 | .. | pause: 1000 0x89-0x8b (2)
+ |00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f|0123456789abcdef| tap{}: (tap) 0x8b-0xa0 (21)
+ | | | blocks[0:1]: 0x8b-0xa0 (21)
+ | | | [0]{}: block 0x8b-0xa0 (21)
+0x80| 13 00 | .. | length: 19 0x8b-0x8d (2)
+ | | | header{}: 0x8d-0xa0 (19)
+0x80| 00 | . | flag: "standard_speed_data" (0) 0x8d-0x8e (1)
+0x80| 00 | . | data_type: "program" (0) 0x8e-0x8f (1)
+0x80| 66| f| program_name: "fqTestProg" 0x8f-0x99 (10)
+0x90|71 54 65 73 74 50 72 6f 67 |qTestProg |
+0x90| 26 00 | &. | data_length: 38 0x99-0x9b (2)
+0x90| 0a 00 | .. | auto_start_line: 10 0x9b-0x9d (2)
+0x90| 26 00 | &. | program_length: 38 0x9d-0x9f (2)
+0x90| 01| .| checksum: 0x1 (valid) 0x9f-0xa0 (1)
+ | | | [2]{}: block 0xa0-0xcd (45)
+0xa0|10 |. | type: "standard_speed_data" (16) 0xa0-0xa1 (1)
+0xa0| e8 03 | .. | pause: 1000 0xa1-0xa3 (2)
+ |00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f|0123456789abcdef| tap{}: (tap) 0xa3-0xcd (42)
+ | | | blocks[0:1]: 0xa3-0xcd (42)
+ | | | [0]{}: block 0xa3-0xcd (42)
+0xa0| 28 00 | (. | length: 40 0xa3-0xa5 (2)
+ | | | data{}: 0xa5-0xcd (40)
+0xa0| ff | . | flag: "standard_speed_data" (255) 0xa5-0xa6 (1)
+0xa0| 00 0a 14 00 20 f5 22 66 71 20| .... ."fq | data: raw bits 0xa6-0xcc (38)
+0xb0|69 73 20 74 68 65 20 62 65 73 74 21 22 0d 00 14|is the best!"...|
+0xc0|0a 00 ec 31 30 0e 00 00 0a 00 00 0d |...10....... |
+0xc0| b6| | .| | checksum: 0xb6 (valid) 0xcc-0xcd (1)
diff --git a/format/tzx/testdata/basic_prog1.tzx b/format/tzx/testdata/basic_prog1.tzx
new file mode 100644
index 000000000..839689428
Binary files /dev/null and b/format/tzx/testdata/basic_prog1.tzx differ
diff --git a/format/tzx/tzx.go b/format/tzx/tzx.go
new file mode 100644
index 000000000..98adb5a02
--- /dev/null
+++ b/format/tzx/tzx.go
@@ -0,0 +1,655 @@
+package tzx
+
+// https://worldofspectrum.net/TZXformat.html
+
+import (
+ "embed"
+
+ "golang.org/x/text/encoding/charmap"
+
+ "github.com/wader/fq/format"
+ "github.com/wader/fq/pkg/decode"
+ "github.com/wader/fq/pkg/interp"
+ "github.com/wader/fq/pkg/scalar"
+)
+
+//go:embed tzx.md
+var tzxFS embed.FS
+
+var tapFormat decode.Group
+
+func init() {
+ interp.RegisterFormat(
+ format.TZX,
+ &decode.Format{
+ Description: "TZX tape format for ZX Spectrum computers",
+ Groups: []*decode.Group{format.Probe},
+ DecodeFn: tzxDecode,
+ Dependencies: []decode.Dependency{
+ {Groups: []*decode.Group{format.TAP}, Out: &tapFormat},
+ },
+ })
+ interp.RegisterFS(tzxFS)
+}
+
+func tzxDecode(d *decode.D) any {
+ d.Endian = decode.LittleEndian
+
+ d.FieldRawLen("signature", 8*8, d.AssertBitBuf([]byte("ZXTape!\x1A")))
+ d.FieldU8("major_version")
+ d.FieldU8("minor_version")
+ decodeBlocks(d)
+
+ return nil
+}
+
+func decodeBlocks(d *decode.D) {
+ d.FieldArray("blocks", func(d *decode.D) {
+ for !d.End() {
+ d.FieldStruct("block", func(d *decode.D) {
+ decodeBlock(d)
+ })
+ }
+ })
+}
+
+func decodeBlock(d *decode.D) {
+ blocks := map[uint64]func(d *decode.D){
+ // ID: 10h (16d) | Standard Speed Data
+ // This block is replayed with the standard Spectrum ROM timing values
+ // (the values in curly brackets in block ID 11). The pilot tone
+ // consists of 8063 pulses if the first data byte (the flag byte)
+ // is < 128, 3223 otherwise.
+ 0x10: func(d *decode.D) {
+ // Pause after this block (ms.) {1000}
+ d.FieldU16("pause")
+
+ // A single TAP Data Block
+ peekBytes := d.PeekBytes(2) // get the TAP data block length
+ length := uint16(peekBytes[1])<<8 | uint16(peekBytes[0]) // bytes are stored in LittleEndian
+ length += 2 // include the two bytes for this value
+ d.FieldFormatLen("tap", int64(length)*8, &tapFormat, nil)
+ },
+
+ // ID: 11h (17d) | Turbo Speed Data
+ // This block is very similar to the normal TAP block but with some
+ // additional info on the timings and other important differences. The
+ // same tape encoding is used as for the standard speed data block. If
+ // a block should use some non-standard sync or pilot tones (i.e. all
+ // sorts of protection schemes) then the next three blocks describe it.
+ 0x11: func(d *decode.D) {
+ d.FieldU16("pilot_pulse") // Length of PILOT pulse {2168}
+ d.FieldU16("sync_pulse_1") // Length of SYNC first pulse {667}
+ d.FieldU16("sync_pulse_2") // Length of SYNC second pulse {735}
+ d.FieldU16("bit0_pulse") // Length of ZERO bit pulse {855}
+ d.FieldU16("bit1_pulse") // Length of ONE bit pulse {1710}
+
+ // Length of PILOT tone (number of pulses)
+ // {8063 header (flag<128), 3223 data (flag>=128)}
+ d.FieldU16("pilot_tone")
+
+ // Used bits in the last byte (other bits should be 0) {8}
+ // e.g. if this is 6, then the bits used (x) in the last byte are: xxxxxx00,
+ // where MSb is the leftmost bit, LSb is the rightmost bit
+ d.FieldU8("used_bits")
+
+ d.FieldU16("pause") // Pause after this block (ms.) {1000}
+ length := d.FieldU24("length") // Length of data that follows
+
+ // Data as in .TAP files
+ d.FieldRawLen("data", int64(length*8))
+ },
+
+ // ID: 12h (18d) | Pure Tone
+ // This will produce a tone which is basically the same as the pilot
+ // tone in 10h and 11h blocks.
+ 0x12: func(d *decode.D) {
+ d.FieldU16("pulse_length") // Length of one pulse in T-states
+ d.FieldU16("pulse_count") // Number of pulses
+ },
+
+ // ID: 13h (19d) | Sequence of Pulses
+ // This will produce N pulses, each having its own timing. Up to 255
+ // pulses can be stored in this block.
+ 0x13: func(d *decode.D) {
+ count := d.FieldU8("pulse_count")
+ d.FieldArray("pulses", func(d *decode.D) {
+ for i := uint64(0); i < count; i++ {
+ d.FieldU16("pulse")
+ }
+ })
+ },
+
+ // ID: 14h (20d) | Pure Data
+ // This is the same as in the turbo loading data block, except that it
+ // has no pilot or sync pulses.
+ 0x14: func(d *decode.D) {
+ d.FieldU16("bit0_pulse") // Length of ZERO bit pulse
+ d.FieldU16("bit1_pulse") // Length of ONE bit pulse
+ d.FieldU8("used_bits") // Used bits in last byte
+ d.FieldU16("pause") // Pause after this block (ms.)
+ length := d.FieldU24("length") // Length of data that follows
+
+ // Data as in .TAP files
+ d.FieldRawLen("data", int64(length*8))
+ },
+
+ // ID: 15h (21d) | Direct Recording
+ // This block is used for tapes which have some parts in a format such
+ // that the turbo loader block cannot be used. This is not like a VOC
+ // file since the information is much more compact. Each sample value
+ // is represented by one bit only (0 for low, 1 for high) which means
+ // that the block will be at most 1/8 the size of the equivalent VOC.
+ // The preferred sampling frequencies are 22050 or 44100 Hz
+ // (158 or 79 T-states/sample).
+ 0x15: func(d *decode.D) {
+ d.FieldU16("t_states") // Number of T-states per sample (bit of data)
+ d.FieldU16("pause") // Pause after this block in milliseconds (ms.)
+ d.FieldU8("used_bits") // Used bits (samples) in last byte of data (1-8)
+ length := d.FieldU24("length") // Length of data that follows
+ d.FieldRawLen("data", int64(length*8)) // Samples data. Each bit represents a state on the EAR port
+ },
+
+ // ID: 18h (24d) | CSW Recording
+ // This block contains a sequence of raw pulses encoded in CSW format
+ // v2 (Compressed Square Wave).
+ 0x18: func(d *decode.D) {
+ length := d.FieldU32("length") // Block length (without these four bytes)
+
+ // NOTE: remove these next 4 fields from the length so
+ // the data size is calculated correctly
+ length -= 2 + 3 + 1 + 4
+
+ // Pause after this block (in ms)
+ d.FieldU16("pause")
+ // Sampling rate
+ d.FieldU24("sample_rate")
+ // Compression type
+ d.FieldU8("compression_type", scalar.UintMapSymStr{0x01: "rle", 0x02: "zrle"})
+ // Number of stored pulses (after decompression)
+ d.FieldU32("stored_pulse_count")
+
+ // CSW data, encoded according to the CSW specification
+ d.FieldRawLen("data", int64(length*8))
+ },
+
+ // ID: 19h (25d) | Generalized Data
+ // This block was developed to represent an extremely wide range of data
+ // encoding techniques. Each loading component (pilot tone, sync pulses,
+ // data) is associated to a specific sequence of pulses, where each
+ // sequence (wave) can contain a different number of pulses from the
+ // others. In this way it is possible to have a situation where bit 0 is
+ // represented with 4 pulses and bit 1 with 8 pulses.
+ 0x19: func(d *decode.D) {
+ length := d.FieldU32("length") // Block length (without these four bytes)
+ // TBD:
+ // Pause uint16 // Pause after this block (ms)
+ // TOTP uint32 // Total number of symbols in pilot/sync block (can be 0)
+ // NPP uint8 // Maximum number of pulses per pilot/sync symbol
+ // ASP uint8 // Number of pilot/sync symbols in the alphabet table (0=256)
+ // TOTD uint32 // Total number of symbols in data stream (can be 0)
+ // NPD uint8 // Maximum number of pulses per data symbol
+ // ASD uint8 // Number of data symbols in the alphabet table (0=256)
+ // PilotSymbols []Symbol // 0x12 SYMDEF[ASP] Pilot and sync symbols definition table
+ // PilotStreams []PilotRLE // 0x12+ (2*NPP+1)*ASP - PRLE[TOTP] Pilot and sync data stream
+ // DataSymbols []Symbol // 0x12+ (TOTP>0)*((2*NPP+1)*ASP)+TOTP*3 - SYMDEF[ASD] Data symbols definition table
+ // DataStreams []uint8 // 0x12+ (TOTP>0)*((2*NPP+1)*ASP)+ TOTP*3+(2*NPD+1)*ASD - BYTE[DS] Data stream
+ d.FieldRawLen("data", int64(length*8))
+ },
+
+ // ID: 20h (32d) | Pause Tape Command
+ // This will make a silence (low amplitude level (0)) for a given time
+ // in milliseconds. If the value is 0 then the emulator or utility should
+ // (in effect) STOP THE TAPE, until the user or emulator requests it.
+ 0x20: func(d *decode.D) {
+ d.FieldU16("pause") // Pause duration in ms.
+ },
+
+ // ID: 21h (33d) | Group Start
+ // This block marks the start of a group of blocks which are to be
+ // treated as one single (composite) block. For each group start block
+ // there must be a group end block. Nesting of groups is not allowed.
+ 0x21: func(d *decode.D) {
+ length := d.FieldU8("length")
+ d.FieldStr("group_name", int(length), charmap.ISO8859_1)
+ },
+
+ // ID: 22h (34d) | Group End
+ // This indicates the end of a group. This block has no body.
+ 0x22: func(d *decode.D) {},
+
+ // JumpTo
+ // ID: 23h (35d)
+ // This block will allow for jumping from one block to another within
+ // the file. All blocks are included in the block count!
+ 0x23: func(d *decode.D) {
+ d.FieldS16("value", scalar.SintMapSymStr{
+ 0: "loop_forever",
+ 1: "next_block",
+ 2: "skip_block",
+ -1: "prev_block",
+ })
+ },
+
+ // ID: 24h (36d) | Loop Start
+ // Indicates a sequence of identical blocks, or of identical groups of
+ // blocks. This block is the same as the FOR statement in BASIC.
+ 0x24: func(d *decode.D) {
+ d.FieldU16("repetitions") // Number of repetitions (greater than 1)
+ },
+
+ // ID: 25h (37d) | Loop End
+ // This is the same as BASIC's NEXT statement. It means that the utility
+ // should jump back to the start of the loop if it hasn't been run for
+ // the specified number of times. This block has no body.
+ 0x25: func(d *decode.D) {},
+
+ // ID: 26h (38d) | Call Sequence
+ // This block is an analogue of the CALL Subroutine statement. It
+ // basically executes a sequence of blocks that are somewhere else and
+ // then goes back to the next block. Because more than one call can be
+ // normally used you can include a list of sequences to be called. CALL
+ // blocks can be used in the LOOP sequences and vice versa. The value
+ // is relative so that you can add some blocks in the beginning of the
+ // file without disturbing the call values.
+ // Look at 'Jump To Block' for reference on the values.
+ 0x26: func(d *decode.D) {
+ count := d.FieldU16("count")
+ d.FieldArray("call_blocks", func(d *decode.D) {
+ for i := uint64(0); i < count; i++ {
+ d.FieldS16("offset")
+ }
+ })
+ },
+
+ // ID: 27h (39d) | Return From Sequence
+ // This block indicates the end of the Called Sequence. The next block
+ // played will be the block after the last CALL block (or the next Call,
+ // if the Call block had multiple calls). This block has no body.
+ 0x27: func(d *decode.D) {},
+
+ // ID: 28h (40d) | Select
+ // This block is useful when the tape consists of two or more separately
+ // loadable parts. With this block it is possible to select one of the
+ // parts and the utility/emulator will start loading from that block.
+ // All offsets are relative signed words.
+ 0x28: func(d *decode.D) {
+ // Length of the whole block (without these two bytes)
+ d.FieldU16("length")
+
+ count := d.FieldU8("count")
+ d.FieldArray("selections", func(d *decode.D) {
+ for i := 0; i < int(count); i++ {
+ d.FieldStruct("selection", func(d *decode.D) {
+ d.FieldS16("offset") // Relative Offset as `signed` value
+ length := d.FieldU8("length") // Length of description text (max 30 chars)
+ d.FieldStr("description", int(length), charmap.ISO8859_1)
+ })
+ }
+ })
+ },
+
+ // ID: 2Ah (42d) | Stop Tape When 48k Mode
+ // When this block is encountered, the tape will stop ONLY if the machine
+ // is an 48K Spectrum. This block is to be used for multi-loading games
+ // that load one level at a time in 48K mode, but load the entire tape at
+ // once if in 128K mode.
+ // This block has no body of its own, but follows the extension rule.
+ 0x2A: func(d *decode.D) {
+ d.FieldU32("length") // Length of the block without these four bytes (0)
+ },
+
+ // ID: 2Bh (43d) | Set Signal Level
+ // This block sets the current signal level to the specified value
+ // (high or low). It should be used whenever it is necessary to avoid
+ // any ambiguities, e.g. with custom loaders which are level-sensitive.
+ 0x2B: func(d *decode.D) {
+ d.FieldU32("length") // Block length (without these four bytes)
+ d.FieldU8("signal_level", scalar.UintMapSymStr{0: "low", 1: "high"})
+ },
+
+ // ID: 30h (48d) | Text Description
+ // This is meant to identify parts of the tape, such as where level 1
+ // starts, where to rewind to when the game ends, etc. This description
+ // is not guaranteed to be shown while the tape is playing, but can be
+ // read while browsing the tape or changing the tape pointer.
+ // The description can be up to 255 characters long.
+ 0x30: func(d *decode.D) {
+ length := d.FieldU8("length")
+ d.FieldStr("description", int(length), charmap.ISO8859_1)
+ },
+
+ // ID: 31h (49d) | Message
+ // This will enable the emulators to display a message for a given time.
+ // This should not stop the tape and it should not make silence. If the
+ // time is 0 then the emulator should wait for the user to press a key.
+ 0x31: func(d *decode.D) {
+ // Time (in seconds) for which the message should be displayed
+ d.FieldU8("display_time")
+ // Length of the text message
+ length := d.FieldU8("length")
+ // Message that should be displayed in ASCII format
+ d.FieldStr("message", int(length), charmap.ISO8859_1)
+ },
+
+ // ID: 32h (50d) | Archive Info
+ // This optional block is used at the beginning of the tape containing
+ // various metadata about the tape.
+ 0x32: func(d *decode.D) {
+ d.FieldU16("length") // Length of the whole block without these two bytes
+ count := d.FieldU8("count") // Number of entries in the archive info
+
+ // the archive strings
+ d.FieldArray("archive_info", func(d *decode.D) {
+ for i := uint64(0); i < count; i++ {
+ d.FieldStruct("entry", func(d *decode.D) {
+ d.FieldU8("id", scalar.UintMapSymStr{
+ 0x00: "title",
+ 0x01: "publisher",
+ 0x02: "author",
+ 0x03: "year",
+ 0x04: "language",
+ 0x05: "category",
+ 0x06: "price",
+ 0x07: "loader",
+ 0x08: "origin",
+ 0xFF: "comment",
+ })
+ length := d.FieldU8("length")
+ d.FieldStr("value", int(length), charmap.ISO8859_1)
+ })
+ }
+ })
+ },
+
+ // ID: 33h (51d) | Hardware Type
+ // This blocks contains information about the hardware that the programs
+ // on this tape use.
+ 0x33: func(d *decode.D) {
+ // Number of machines and hardware types for which info is supplied
+ count := d.FieldU8("count")
+ d.FieldArray("hardware_info", func(d *decode.D) {
+ for i := uint64(0); i < count; i++ {
+ d.FieldStruct("info", func(d *decode.D) {
+ // Hardware Type ID (computers, printers, mice, etc.)
+ typeId := d.FieldU8("type", hwInfoTypeMapper)
+ // Hardware ID (ZX81, Kempston Joystick, etc.)
+ d.FieldU8("id", hwInfoTypeIdMapper[typeId])
+ // Hardware compatibility information
+ d.FieldU8("info_id", hwInfoIdMapper)
+ })
+ }
+ })
+ },
+
+ // ID: 35h (53d) | Custom Info
+ // This block contains various custom data. For example, it might contain
+ // some information written by a utility, extra settings required by a
+ // particular emulator, etc.
+ 0x35: func(d *decode.D) {
+ d.FieldStr("identification", 10, charmap.ISO8859_1)
+ length := d.FieldU32("length")
+ d.FieldRawLen("info", int64(length*8))
+ },
+
+ // ID: 5Ah (90d) | Glue Block
+ // This block is generated when two ZX Tape files are merged together.
+ // It is here so that you can easily copy the files together and use
+ // them. Of course, this means that resulting file would be 10 bytes
+ // longer than if this block was not used. All you have to do if you
+ // encounter this block ID is to skip next 9 bytes. If you can avoid
+ // using this block for this purpose, then do so; it is preferable to
+ // use a utility to join the two files and ensure that they are both
+ // of the higher version number.
+ 0x5A: func(d *decode.D) {
+ // Value: { "XTape!",0x1A,MajR,MinR }
+ // Just skip these 9 bytes and you will end up on the next ID.
+ d.FieldRawLen("value", int64(9*8))
+ },
+ }
+
+ blockType := d.FieldU8("type", blockTypeMapper)
+
+ // Deprecated block types: C64RomType, C64TurboData, EmulationInfo, Snapshot
+ if blockType == 0x16 || blockType == 0x17 || blockType == 0x34 || blockType == 0x40 {
+ d.Fatalf("deprecated block type encountered: %02x", blockType)
+ }
+
+ if fn, ok := blocks[blockType]; ok {
+ fn(d)
+ } else {
+ d.Fatalf("block type not valid, got: %02x", blockType)
+ }
+}
+
+var blockTypeMapper = scalar.UintMapSymStr{
+ 0x10: "standard_speed_data",
+ 0x11: "turbo_speed_data",
+ 0x12: "pure_tone",
+ 0x13: "sequence_of_pulses",
+ 0x14: "pure_data",
+ 0x15: "direct_recording", // deprecated
+ 0x16: "c64_rom_type", // deprecated
+ 0x17: "c64_turbo_data",
+ 0x18: "csw_recording",
+ 0x19: "generalized_data",
+ 0x20: "pause_tape_command",
+ 0x21: "group_start",
+ 0x22: "group_end",
+ 0x23: "jump_to",
+ 0x24: "loop_start",
+ 0x25: "loop_end",
+ 0x26: "call_sequence",
+ 0x27: "return_from_sequence",
+ 0x28: "select",
+ 0x2A: "stop_tape_when_48k_mode",
+ 0x2B: "set_signal_level",
+ 0x30: "text_description",
+ 0x31: "message",
+ 0x32: "archive_info",
+ 0x33: "hardware_type",
+ 0x34: "emulation_info", // deprecated
+ 0x35: "custom_info",
+ 0x40: "snapshot", // deprecated
+ 0x5A: "glue_block",
+}
+
+var hwInfoTypeMapper = scalar.UintMapDescription{
+ 0x00: "Computers",
+ 0x01: "External storage",
+ 0x02: "ROM/RAM type add-ons",
+ 0x03: "Sound devices",
+ 0x04: "Joysticks",
+ 0x05: "Mice",
+ 0x06: "Other controllers",
+ 0x07: "Serial ports",
+ 0x08: "Parallel ports",
+ 0x09: "Printers",
+ 0x0a: "Modems",
+ 0x0b: "Digitizers",
+ 0x0c: "Network adapters",
+ 0x0d: "Keyboards & keypads",
+ 0x0e: "AD/DA converters",
+ 0x0f: "EPROM programmers",
+ 0x10: "Graphics",
+}
+
+var hwInfoTypeIdMapper = map[uint64]scalar.UintMapDescription{
+ 0x00: { // Computers
+ 0x00: "ZX Spectrum 16k",
+ 0x01: "ZX Spectrum 48k, Plus",
+ 0x02: "ZX Spectrum 48k ISSUE 1",
+ 0x03: "ZX Spectrum 128k +(Sinclair)",
+ 0x04: "ZX Spectrum 128k +2 (grey case)",
+ 0x05: "ZX Spectrum 128k +2A, +3",
+ 0x06: "Timex Sinclair TC-2048",
+ 0x07: "Timex Sinclair TS-2068",
+ 0x08: "Pentagon 128",
+ 0x09: "Sam Coupe",
+ 0x0a: "Didaktik M",
+ 0x0b: "Didaktik Gama",
+ 0x0c: "ZX-80",
+ 0x0d: "ZX-81",
+ 0x0e: "ZX Spectrum 128k, Spanish version",
+ 0x0f: "ZX Spectrum, Arabic version",
+ 0x10: "Microdigital TK 90-X",
+ 0x11: "Microdigital TK 95",
+ 0x12: "Byte",
+ 0x13: "Elwro 800-3 ",
+ 0x14: "ZS Scorpion 256",
+ 0x15: "Amstrad CPC 464",
+ 0x16: "Amstrad CPC 664",
+ 0x17: "Amstrad CPC 6128",
+ 0x18: "Amstrad CPC 464+",
+ 0x19: "Amstrad CPC 6128+",
+ 0x1a: "Jupiter ACE",
+ 0x1b: "Enterprise",
+ 0x1c: "Commodore 64",
+ 0x1d: "Commodore 128",
+ 0x1e: "Inves Spectrum+",
+ 0x1f: "Profi",
+ 0x20: "GrandRomMax",
+ 0x21: "Kay 1024",
+ 0x22: "Ice Felix HC 91",
+ 0x23: "Ice Felix HC 2000",
+ 0x24: "Amaterske RADIO Mistrum",
+ 0x25: "Quorum 128",
+ 0x26: "MicroART ATM",
+ 0x27: "MicroART ATM Turbo 2",
+ 0x28: "Chrome",
+ 0x29: "ZX Badaloc",
+ 0x2a: "TS-1500",
+ 0x2b: "Lambda",
+ 0x2c: "TK-65",
+ 0x2d: "ZX-97",
+ },
+ 0x01: { // External storage
+ 0x00: "ZX Microdrive",
+ 0x01: "Opus Discovery",
+ 0x02: "MGT Disciple",
+ 0x03: "MGT Plus-D",
+ 0x04: "Rotronics Wafadrive",
+ 0x05: "TR-DOS (BetaDisk)",
+ 0x06: "Byte Drive",
+ 0x07: "Watsford",
+ 0x08: "FIZ",
+ 0x09: "Radofin",
+ 0x0a: "Didaktik disk drives",
+ 0x0b: "BS-DOS (MB-02)",
+ 0x0c: "ZX Spectrum +3 disk drive",
+ 0x0d: "JLO (Oliger) disk interface",
+ 0x0e: "Timex FDD3000",
+ 0x0f: "Zebra disk drive",
+ 0x10: "Ramex Millennia",
+ 0x11: "Larken",
+ 0x12: "Kempston disk interface",
+ 0x13: "Sandy",
+ 0x14: "ZX Spectrum +3e hard disk",
+ 0x15: "ZXATASP",
+ 0x16: "DivIDE",
+ 0x17: "ZXCF",
+ },
+ 0x02: { // ROM/RAM type add_ons
+ 0x00: "Sam Ram",
+ 0x01: "Multiface ONE",
+ 0x02: "Multiface 128k",
+ 0x03: "Multiface +3",
+ 0x04: "MultiPrint",
+ 0x05: "MB-02 ROM/RAM expansion",
+ 0x06: "SoftROM",
+ 0x07: "1k",
+ 0x08: "16k",
+ 0x09: "48k",
+ 0x0a: "Memory in 8-16k used",
+ },
+ 0x03: { // Sound devices
+ 0x00: "Classic AY hardware (compatible with 128k ZXs)",
+ 0x01: "Fuller Box AY sound hardware",
+ 0x02: "Currah microSpeech",
+ 0x03: "SpecDrum",
+ 0x04: "AY ACB stereo (A+C=left, B+C=right); Melodik",
+ 0x05: "AY ABC stereo (A+B=left, B+C=right)",
+ 0x06: "RAM Music Machine",
+ 0x07: "Covox",
+ 0x08: "General Sound",
+ 0x09: "Intec Electronics Digital Interface B8001",
+ 0x0a: "Zon-X AY",
+ 0x0b: "QuickSilva AY",
+ 0x0c: "Jupiter ACE",
+ },
+ 0x04: { // Joysticks
+ 0x00: "Kempston",
+ 0x01: "Cursor, Protek, AGF",
+ 0x02: "Sinclair 2 Left (12345)",
+ 0x03: "Sinclair 1 Right (67890)",
+ 0x04: "Fuller",
+ },
+ 0x05: { // Mice
+ 0x00: "AMX mouse",
+ 0x01: "Kempston mouse",
+ },
+ 0x06: { // Other controllers
+ 0x00: "Trickstick",
+ 0x01: "ZX Light Gun",
+ 0x02: "Zebra Graphics Tablet",
+ 0x03: "Defender Light Gun",
+ },
+ 0x07: { // Serial ports
+ 0x00: "ZX Interface 1",
+ 0x01: "ZX Spectrum 128k",
+ },
+ 0x08: { // Parallel ports
+ 0x00: "Kempston S",
+ 0x01: "Kempston E",
+ 0x02: "ZX Spectrum +3",
+ 0x03: "Tasman",
+ 0x04: "DK'Tronics",
+ 0x05: "Hilderbay",
+ 0x06: "INES Printerface",
+ 0x07: "ZX LPrint Interface 3",
+ 0x08: "MultiPrint",
+ 0x09: "Opus Discovery",
+ 0x0a: "Standard 8255 chip with ports 31,63,95",
+ },
+ 0x09: { // Printers
+ 0x00: "ZX Printer, Alphacom 32 & compatibles",
+ 0x01: "Generic printer",
+ 0x02: "EPSON compatible",
+ },
+ 0x0a: { // Modems
+ 0x00: "Prism VTX 5000",
+ 0x01: "T/S 2050 or Westridge 2050",
+ },
+ 0x0b: { // Digitizers
+ 0x00: "RD Digital Tracer",
+ 0x01: "DK'Tronics Light Pen",
+ 0x02: "British MicroGraph Pad",
+ 0x03: "Romantic Robot Videoface",
+ },
+ 0x0c: { // Network adapters
+ 0x00: "ZX Interface 1",
+ },
+ 0x0d: { // Keyboards & keypads
+ 0x00: "Keypad for ZX Spectrum 128k",
+ },
+ 0x0e: { // AD/DA converters
+ 0x00: "Harley Systems ADC 8.2",
+ 0x01: "Blackboard Electronics",
+ },
+ 0x0f: { // EPROM programmers
+ 0x00: "Orme Electronics",
+ },
+ 0x10: { // Graphics
+ 0x00: "WRX Hi-Res",
+ 0x01: "G007",
+ 0x02: "Memotech",
+ 0x03: "Lambda Colour",
+ },
+}
+
+var hwInfoIdMapper = scalar.UintMapDescription{
+ 00: "RUNS on this machine or with this hardware, but may or may not use the hardware or special features of the machine.",
+ 01: "USES the hardware or special features of the machine, such as extra memory or a sound chip.",
+ 02: "RUNS but it DOESN'T use the hardware or special features of the machine.",
+ 03: "DOESN'T RUN on this machine or with this hardware.",
+}
diff --git a/format/tzx/tzx.md b/format/tzx/tzx.md
new file mode 100644
index 000000000..d713dde3d
--- /dev/null
+++ b/format/tzx/tzx.md
@@ -0,0 +1,17 @@
+`TZX` is a file format designed to preserve cassette tapes compatible with the
+ZX Spectrum computers, although some specialized versions of the format have
+been defined for other machines such as the Amstrad CPC and C64.
+
+The format was originally created by Tomaz Kac, who was maintainer until
+`revision 1.13`, before passing it to Martijn v.d. Heide. For a brief period
+the company Ramsoft became the maintainers, and created revision `v1.20`.
+
+The default file extension is `.tzx`.
+
+### Authors
+
+- Michael R. Cook work.mrc@pm.me, original author
+
+### References
+
+- https://worldofspectrum.net/TZXformat.html