@zx-vibes/machine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ // Regenerated `.tzx` tape-image codec, authored from the project DNA
2
+ // (dna/domain/file-formats.md "`.tzx` — tape image (versioned block stream)") and
3
+ // decided by the format conformance fixtures (dna/conformance/formats/tzx-format.json).
4
+ // A `.tzx` file is a 10-byte header ("ZXTape!" + 0x1A + major + minor) followed by a
5
+ // flat sequence of typed blocks, each introduced by a 1-byte block ID. Every multi-byte
6
+ // field is little-endian; the turbo (0x11) and pure-data (0x14) blocks carry a 3-byte
7
+ // length, NOT a 2-byte WORD. This slice models the nine common block IDs (0x10, 0x11,
8
+ // 0x12, 0x13, 0x14, 0x20, 0x21, 0x22, 0x30) against the pinned TZX v1.20 spec; an
9
+ // unsupported ID is rejected rather than skipped. Tape, like a snapshot, is a file the
10
+ // machine loads, so this lives beside the .tap/.z80 codecs.
11
+
12
+ // FMT-TZX-HEADER-001: the 7 ASCII signature bytes (the 0x1A end-of-text marker is
13
+ // separate) and the pinned v1.20.
14
+ export const TZX_SIGNATURE = "ZXTape!";
15
+ export const TZX_VERSION = { major: 1, minor: 20 };
16
+
17
+ const SIGNATURE_BYTES = Uint8Array.from([0x5a, 0x58, 0x54, 0x61, 0x70, 0x65, 0x21]); // "ZXTape!"
18
+ const EOT_MARKER = 0x1a;
19
+ const HEADER_LENGTH = 10; // 7 signature + 1 marker + 1 major + 1 minor
20
+
21
+ function toBytes(value) {
22
+ if (value instanceof Uint8Array) return value;
23
+ if (Array.isArray(value)) return Uint8Array.from(value);
24
+ return Uint8Array.from(value ?? []);
25
+ }
26
+
27
+ // FMT-TZX-TEXT-0x30-001 / FMT-TZX-GROUP-0x21-0x22-001: group name / text are ASCII
28
+ // (Latin-1) strings. Decode/encode byte-for-byte so any 0x00..0xFF round-trips.
29
+ function bytesToLatin1(bytes, start, length) {
30
+ let out = "";
31
+ for (let i = 0; i < length; i += 1) out += String.fromCharCode(bytes[start + i] & 0xff);
32
+ return out;
33
+ }
34
+ function latin1ToBytes(text) {
35
+ const str = String(text ?? "");
36
+ const out = new Uint8Array(str.length);
37
+ for (let i = 0; i < str.length; i += 1) out[i] = str.charCodeAt(i) & 0xff;
38
+ return out;
39
+ }
40
+
41
+ // A small front-to-back reader over a Uint8Array. Throws if a read runs past the end —
42
+ // this is how a truncated block is rejected (FMT-TZX-BLOCK-STREAM-001).
43
+ class Reader {
44
+ constructor(bytes) {
45
+ this.bytes = bytes;
46
+ this.offset = 0;
47
+ }
48
+ remaining() {
49
+ return this.bytes.length - this.offset;
50
+ }
51
+ need(n, what) {
52
+ if (this.offset + n > this.bytes.length) {
53
+ throw new Error(`parseTzx: truncated ${what} at offset ${this.offset} (need ${n}, have ${this.remaining()})`);
54
+ }
55
+ }
56
+ u8(what) {
57
+ this.need(1, what);
58
+ return this.bytes[this.offset++] & 0xff;
59
+ }
60
+ u16(what) {
61
+ // FMT-TZX-ENDIAN-001: little-endian WORD.
62
+ this.need(2, what);
63
+ const v = this.bytes[this.offset] | (this.bytes[this.offset + 1] << 8);
64
+ this.offset += 2;
65
+ return v & 0xffff;
66
+ }
67
+ u24(what) {
68
+ // FMT-TZX-ENDIAN-001: 3-byte little-endian length (turbo/pure-data).
69
+ this.need(3, what);
70
+ const v =
71
+ this.bytes[this.offset] |
72
+ (this.bytes[this.offset + 1] << 8) |
73
+ (this.bytes[this.offset + 2] << 16);
74
+ this.offset += 3;
75
+ return v >>> 0;
76
+ }
77
+ take(n, what) {
78
+ this.need(n, what);
79
+ const slice = this.bytes.slice(this.offset, this.offset + n);
80
+ this.offset += n;
81
+ return slice;
82
+ }
83
+ }
84
+
85
+ // Per-ID block-body readers. Each returns the structured block; the reader's offset is
86
+ // advanced exactly past the block body, so the caller resumes at the next block ID.
87
+ const BLOCK_READERS = {
88
+ // FMT-TZX-DATA-0x10-001: pause (WORD), length (WORD), data.
89
+ 0x10(r) {
90
+ const pause = r.u16("0x10 pause");
91
+ const length = r.u16("0x10 length");
92
+ const data = r.take(length, "0x10 data");
93
+ return { id: 0x10, pause, data };
94
+ },
95
+ // FMT-TZX-TURBO-0x11-001: 18-byte fixed header, then a 3-BYTE length, then data.
96
+ 0x11(r) {
97
+ const pilot = r.u16("0x11 pilot");
98
+ const sync1 = r.u16("0x11 sync1");
99
+ const sync2 = r.u16("0x11 sync2");
100
+ const zero = r.u16("0x11 zero");
101
+ const one = r.u16("0x11 one");
102
+ const pilotPulses = r.u16("0x11 pilotPulses");
103
+ const usedBits = r.u8("0x11 usedBits");
104
+ const pause = r.u16("0x11 pause");
105
+ const length = r.u24("0x11 length");
106
+ const data = r.take(length, "0x11 data");
107
+ return { id: 0x11, pilot, sync1, sync2, zero, one, pilotPulses, usedBits, pause, data };
108
+ },
109
+ // FMT-TZX-TONE-0x12-001: pulse length (WORD), pulse count (WORD); no data.
110
+ 0x12(r) {
111
+ const pulseLength = r.u16("0x12 pulseLength");
112
+ const pulseCount = r.u16("0x12 pulseCount");
113
+ return { id: 0x12, pulseLength, pulseCount };
114
+ },
115
+ // FMT-TZX-PULSES-0x13-001: count (BYTE), then count little-endian WORD pulse lengths.
116
+ 0x13(r) {
117
+ const count = r.u8("0x13 count");
118
+ const pulses = [];
119
+ for (let i = 0; i < count; i += 1) pulses.push(r.u16(`0x13 pulse[${i}]`));
120
+ return { id: 0x13, pulses };
121
+ },
122
+ // FMT-TZX-PUREDATA-0x14-001: 10-byte fixed header, then a 3-BYTE length, then data.
123
+ 0x14(r) {
124
+ const zero = r.u16("0x14 zero");
125
+ const one = r.u16("0x14 one");
126
+ const usedBits = r.u8("0x14 usedBits");
127
+ const pause = r.u16("0x14 pause");
128
+ const length = r.u24("0x14 length");
129
+ const data = r.take(length, "0x14 data");
130
+ return { id: 0x14, zero, one, usedBits, pause, data };
131
+ },
132
+ // FMT-TZX-PAUSE-0x20-001: pause duration (WORD); 0 = stop the tape.
133
+ 0x20(r) {
134
+ const pause = r.u16("0x20 pause");
135
+ return { id: 0x20, pause };
136
+ },
137
+ // FMT-TZX-GROUP-0x21-0x22-001: name length (BYTE), then ASCII name.
138
+ 0x21(r) {
139
+ const length = r.u8("0x21 name length");
140
+ const bytes = r.take(length, "0x21 name");
141
+ return { id: 0x21, name: bytesToLatin1(bytes, 0, bytes.length) };
142
+ },
143
+ // FMT-TZX-GROUP-0x21-0x22-001: group end, no body.
144
+ 0x22() {
145
+ return { id: 0x22 };
146
+ },
147
+ // FMT-TZX-TEXT-0x30-001: text length (BYTE), then ASCII text.
148
+ 0x30(r) {
149
+ const length = r.u8("0x30 text length");
150
+ const bytes = r.take(length, "0x30 text");
151
+ return { id: 0x30, text: bytesToLatin1(bytes, 0, bytes.length) };
152
+ },
153
+ };
154
+
155
+ // FMT-TZX-HEADER-001 / FMT-TZX-BLOCK-STREAM-001: validate the header, then walk blocks.
156
+ export function parseTzx(bytes) {
157
+ const file = toBytes(bytes);
158
+ if (file.length < HEADER_LENGTH) {
159
+ throw new Error(`parseTzx: file too short for a TZX header (${file.length} < ${HEADER_LENGTH})`);
160
+ }
161
+ for (let i = 0; i < SIGNATURE_BYTES.length; i += 1) {
162
+ if ((file[i] & 0xff) !== SIGNATURE_BYTES[i]) {
163
+ throw new Error('parseTzx: bad signature (expected "ZXTape!")');
164
+ }
165
+ }
166
+ if ((file[7] & 0xff) !== EOT_MARKER) {
167
+ throw new Error("parseTzx: missing 0x1A end-of-text marker after signature");
168
+ }
169
+ const version = { major: file[8] & 0xff, minor: file[9] & 0xff };
170
+
171
+ const r = new Reader(file);
172
+ r.offset = HEADER_LENGTH;
173
+ const blocks = [];
174
+ while (r.remaining() > 0) {
175
+ const id = r.u8("block id");
176
+ const read = BLOCK_READERS[id];
177
+ if (!read) {
178
+ throw new Error(`parseTzx: unsupported block id 0x${id.toString(16)} at offset ${r.offset - 1}`);
179
+ }
180
+ blocks.push(read(r));
181
+ }
182
+ return { version, blocks };
183
+ }
184
+
185
+ // Per-ID block-body writers, the exact inverse of BLOCK_READERS. Each pushes the body
186
+ // bytes (no leading ID) onto `out`.
187
+ function pushU16(out, value) {
188
+ out.push(value & 0xff, (value >> 8) & 0xff);
189
+ }
190
+ function pushU24(out, value) {
191
+ out.push(value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff);
192
+ }
193
+
194
+ const BLOCK_WRITERS = {
195
+ 0x10(out, b) {
196
+ const data = toBytes(b.data);
197
+ pushU16(out, b.pause ?? 0);
198
+ pushU16(out, data.length);
199
+ for (const byte of data) out.push(byte & 0xff);
200
+ },
201
+ 0x11(out, b) {
202
+ const data = toBytes(b.data);
203
+ pushU16(out, b.pilot ?? 0);
204
+ pushU16(out, b.sync1 ?? 0);
205
+ pushU16(out, b.sync2 ?? 0);
206
+ pushU16(out, b.zero ?? 0);
207
+ pushU16(out, b.one ?? 0);
208
+ pushU16(out, b.pilotPulses ?? 0);
209
+ out.push((b.usedBits ?? 0) & 0xff);
210
+ pushU16(out, b.pause ?? 0);
211
+ pushU24(out, data.length);
212
+ for (const byte of data) out.push(byte & 0xff);
213
+ },
214
+ 0x12(out, b) {
215
+ pushU16(out, b.pulseLength ?? 0);
216
+ pushU16(out, b.pulseCount ?? 0);
217
+ },
218
+ 0x13(out, b) {
219
+ const pulses = b.pulses ?? [];
220
+ out.push(pulses.length & 0xff);
221
+ for (const p of pulses) pushU16(out, p);
222
+ },
223
+ 0x14(out, b) {
224
+ const data = toBytes(b.data);
225
+ pushU16(out, b.zero ?? 0);
226
+ pushU16(out, b.one ?? 0);
227
+ out.push((b.usedBits ?? 0) & 0xff);
228
+ pushU16(out, b.pause ?? 0);
229
+ pushU24(out, data.length);
230
+ for (const byte of data) out.push(byte & 0xff);
231
+ },
232
+ 0x20(out, b) {
233
+ pushU16(out, b.pause ?? 0);
234
+ },
235
+ 0x21(out, b) {
236
+ const name = latin1ToBytes(b.name);
237
+ out.push(name.length & 0xff);
238
+ for (const byte of name) out.push(byte & 0xff);
239
+ },
240
+ 0x22() {
241
+ // no body
242
+ },
243
+ 0x30(out, b) {
244
+ const text = latin1ToBytes(b.text);
245
+ out.push(text.length & 0xff);
246
+ for (const byte of text) out.push(byte & 0xff);
247
+ },
248
+ };
249
+
250
+ // FMT-TZX-ROUNDTRIP-001: emit the 10-byte header, then each block as [id][body].
251
+ export function serializeTzx(tzx) {
252
+ const version = tzx?.version ?? TZX_VERSION;
253
+ const blocks = tzx?.blocks ?? [];
254
+ const out = [];
255
+ for (const byte of SIGNATURE_BYTES) out.push(byte);
256
+ out.push(EOT_MARKER);
257
+ out.push((version.major ?? TZX_VERSION.major) & 0xff);
258
+ out.push((version.minor ?? TZX_VERSION.minor) & 0xff);
259
+ for (const block of blocks) {
260
+ const write = BLOCK_WRITERS[block.id];
261
+ if (!write) {
262
+ throw new Error(`serializeTzx: unsupported block id 0x${(block.id ?? -1).toString(16)}`);
263
+ }
264
+ out.push(block.id & 0xff);
265
+ write(out, block);
266
+ }
267
+ return Uint8Array.from(out);
268
+ }