@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,39 @@
1
+ export function compressZ80(bytes: any): Uint8Array<ArrayBuffer>;
2
+ export function decompressZ80(bytes: any, expectedLength: any): Uint8Array<ArrayBuffer>;
3
+ export function writeZ80({ registers, memory, border }?: {
4
+ registers?: {};
5
+ border?: number;
6
+ }): Uint8Array<ArrayBuffer>;
7
+ export function readZ80(bytes: any): {
8
+ registers: {
9
+ a: number;
10
+ f: number;
11
+ c: number;
12
+ b: number;
13
+ l: number;
14
+ h: number;
15
+ sp: number;
16
+ i: number;
17
+ r: number;
18
+ e: number;
19
+ d: number;
20
+ c_: number;
21
+ b_: number;
22
+ e_: number;
23
+ d_: number;
24
+ l_: number;
25
+ h_: number;
26
+ a_: number;
27
+ f_: number;
28
+ iyl: number;
29
+ iyh: number;
30
+ ixl: number;
31
+ ixh: number;
32
+ iff1: number;
33
+ iff2: number;
34
+ im: number;
35
+ };
36
+ memory: Uint8Array<ArrayBuffer>;
37
+ border: number;
38
+ version: number;
39
+ };
@@ -0,0 +1,164 @@
1
+ // .z80 snapshot format (read/write) for the regenerated 48K machine, authored
2
+ // from the project DNA (dna/domain/snapshot-z80.md) and decided by the format
3
+ // conformance fixtures (dna/conformance/formats/z80-v3-roundtrip.json).
4
+ //
5
+ // The .z80 file format is the de-facto community ZX Spectrum snapshot format
6
+ // (Gerton Lunter). This module implements the 48K variant: it WRITES version 3
7
+ // (PC=0 marker, 54-byte extra header, hardware mode 0 = 48K, three RLE-compressed
8
+ // 16K memory pages 8/4/5) and READS versions 1, 2 and 3. The state is the machine
9
+ // register file + the 64 KB memory image + the border colour; round-tripping it
10
+ // preserves RAM and registers (FMT-Z80-V3-001).
11
+
12
+ const PAGE_TO_BASE = { 8: 0x4000, 4: 0x8000, 5: 0xc000 }; // 48K page numbers -> address
13
+ const BASE_TO_PAGE = [[0x4000, 8], [0x8000, 4], [0xc000, 5]];
14
+
15
+ // --- RLE (the .z80 "ED ED count value" run encoding) -------------------------
16
+ // A run of >= 5 equal bytes (or >= 2 equal 0xED bytes, since a literal "ED ED"
17
+ // would be misread as the marker) is encoded as ED ED count value, count <= 255.
18
+ export function compressZ80(bytes) {
19
+ const out = [];
20
+ let i = 0;
21
+ while (i < bytes.length) {
22
+ const v = bytes[i];
23
+ let run = 1;
24
+ while (i + run < bytes.length && bytes[i + run] === v && run < 255) run += 1;
25
+ const worthRun = v === 0xed ? run >= 2 : run >= 5;
26
+ if (worthRun) {
27
+ out.push(0xed, 0xed, run, v);
28
+ i += run;
29
+ } else if (v === 0xed) {
30
+ // A lone ED (run of 1) must not start a literal "ED xx" that could be ED ED:
31
+ // emit the ED and the following byte verbatim as a pair so it is never a marker.
32
+ out.push(0xed);
33
+ i += 1;
34
+ if (i < bytes.length) { out.push(bytes[i]); i += 1; }
35
+ } else {
36
+ out.push(v);
37
+ i += 1;
38
+ }
39
+ }
40
+ return Uint8Array.from(out);
41
+ }
42
+
43
+ export function decompressZ80(bytes, expectedLength) {
44
+ const out = [];
45
+ let i = 0;
46
+ while (i < bytes.length && (expectedLength === undefined || out.length < expectedLength)) {
47
+ if (bytes[i] === 0xed && bytes[i + 1] === 0xed) {
48
+ const count = bytes[i + 2];
49
+ const value = bytes[i + 3];
50
+ for (let k = 0; k < count; k += 1) out.push(value);
51
+ i += 4;
52
+ } else {
53
+ out.push(bytes[i]);
54
+ i += 1;
55
+ }
56
+ }
57
+ return Uint8Array.from(out);
58
+ }
59
+
60
+ const REG = (r, name) => (r[name] ?? 0) & 0xff;
61
+ const word = (lo, hi) => (lo & 0xff) | ((hi & 0xff) << 8);
62
+
63
+ export function writeZ80({ registers = {}, memory, border = 0 } = {}) {
64
+ const r = registers;
65
+ const mem = memory instanceof Uint8Array ? memory : Uint8Array.from(memory ?? []);
66
+ const h = new Uint8Array(30 + 2 + 54);
67
+ // v1 header (PC at 6-7 = 0 => version 2/3)
68
+ h[0] = REG(r, "a"); h[1] = REG(r, "f");
69
+ h[2] = REG(r, "c"); h[3] = REG(r, "b");
70
+ h[4] = REG(r, "l"); h[5] = REG(r, "h");
71
+ h[6] = 0; h[7] = 0; // PC = 0 -> v2/v3
72
+ h[8] = REG(r, "sp") ; h[9] = (r.sp ?? 0) >> 8 & 0xff;
73
+ h[10] = REG(r, "i");
74
+ h[11] = REG(r, "r") & 0x7f;
75
+ h[12] = (((r.r ?? 0) >> 7) & 1) | ((border & 0x07) << 1);
76
+ h[13] = REG(r, "e"); h[14] = REG(r, "d");
77
+ h[15] = REG(r, "c_"); h[16] = REG(r, "b_");
78
+ h[17] = REG(r, "e_"); h[18] = REG(r, "d_");
79
+ h[19] = REG(r, "l_"); h[20] = REG(r, "h_");
80
+ h[21] = REG(r, "a_"); h[22] = REG(r, "f_");
81
+ h[23] = REG(r, "iyl"); h[24] = REG(r, "iyh");
82
+ h[25] = REG(r, "ixl"); h[26] = REG(r, "ixh");
83
+ h[27] = (r.iff1 ?? 0) ? 1 : 0;
84
+ h[28] = (r.iff2 ?? 0) ? 1 : 0;
85
+ h[29] = (r.im ?? 0) & 0x03;
86
+ // v3 extra header: length 54, PC, hardware mode 0 (48K).
87
+ h[30] = 54; h[31] = 0;
88
+ h[32] = REG(r, "pc"); h[33] = ((r.pc ?? 0) >> 8) & 0xff;
89
+ h[34] = 0; // hardware mode 0 = 48K
90
+ // bytes 35..83 left 0 (no extra hardware state)
91
+
92
+ const blocks = [];
93
+ for (const [base, page] of BASE_TO_PAGE) {
94
+ const raw = mem.subarray(base, base + 0x4000);
95
+ const comp = compressZ80(raw);
96
+ const len = comp.length;
97
+ const block = new Uint8Array(3 + len);
98
+ block[0] = len & 0xff; block[1] = (len >> 8) & 0xff; block[2] = page;
99
+ block.set(comp, 3);
100
+ blocks.push(block);
101
+ }
102
+ const total = h.length + blocks.reduce((s, b) => s + b.length, 0);
103
+ const out = new Uint8Array(total);
104
+ out.set(h, 0);
105
+ let off = h.length;
106
+ for (const b of blocks) { out.set(b, off); off += b.length; }
107
+ return out;
108
+ }
109
+
110
+ export function readZ80(bytes) {
111
+ const b = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
112
+ const registers = {
113
+ a: b[0], f: b[1], c: b[2], b: b[3], l: b[4], h: b[5],
114
+ sp: word(b[8], b[9]), i: b[10],
115
+ r: (b[11] & 0x7f) | ((b[12] & 1) << 7),
116
+ e: b[13], d: b[14],
117
+ c_: b[15], b_: b[16], e_: b[17], d_: b[18], l_: b[19], h_: b[20],
118
+ a_: b[21], f_: b[22],
119
+ iyl: b[23], iyh: b[24], ixl: b[25], ixh: b[26],
120
+ iff1: b[27] ? 1 : 0, iff2: b[28] ? 1 : 0, im: b[29] & 0x03,
121
+ };
122
+ const border = (b[12] >> 1) & 0x07;
123
+ const memory = new Uint8Array(0x10000);
124
+
125
+ const v1pc = word(b[6], b[7]);
126
+ if (v1pc !== 0) {
127
+ // Version 1: single 48K image from 0x4000, optionally RLE-compressed (byte12 bit5).
128
+ registers.pc = v1pc;
129
+ const compressed = ((b[12] === 255 ? 1 : b[12]) & 0x20) !== 0;
130
+ const body = b.subarray(30);
131
+ const ram = compressed ? decompressZ80(stripV1End(body), 0xc000) : body.subarray(0, 0xc000);
132
+ memory.set(ram.subarray(0, 0xc000), 0x4000);
133
+ return { registers, memory, border, version: 1 };
134
+ }
135
+
136
+ // Version 2/3: extra header then page blocks.
137
+ const extraLen = word(b[30], b[31]);
138
+ registers.pc = word(b[32], b[33]);
139
+ const version = extraLen === 23 ? 2 : 3;
140
+ let off = 30 + 2 + extraLen;
141
+ while (off + 3 <= b.length) {
142
+ const len = word(b[off], b[off + 1]);
143
+ const page = b[off + 2];
144
+ off += 3;
145
+ const base = PAGE_TO_BASE[page];
146
+ const uncompressed = len === 0xffff;
147
+ const blockLen = uncompressed ? 0x4000 : len;
148
+ const slice = b.subarray(off, off + blockLen);
149
+ off += blockLen;
150
+ if (base === undefined) continue; // ignore non-48K pages
151
+ const ram = uncompressed ? slice : decompressZ80(slice, 0x4000);
152
+ memory.set(ram.subarray(0, 0x4000), base);
153
+ }
154
+ return { registers, memory, border, version };
155
+ }
156
+
157
+ // A compressed v1 body ends with the marker 00 ED ED 00; strip it before decoding.
158
+ function stripV1End(body) {
159
+ const n = body.length;
160
+ if (n >= 4 && body[n - 4] === 0x00 && body[n - 3] === 0xed && body[n - 2] === 0xed && body[n - 1] === 0x00) {
161
+ return body.subarray(0, n - 4);
162
+ }
163
+ return body;
164
+ }
@@ -0,0 +1,7 @@
1
+ export function tapChecksum(flag: any, data: any): number;
2
+ export function parseTap(bytes: any): {
3
+ flag: number;
4
+ data: Uint8Array<ArrayBuffer>;
5
+ checksum: number;
6
+ }[];
7
+ export function serializeTap(blocks: any): Uint8Array<ArrayBuffer>;
@@ -0,0 +1,74 @@
1
+ // Regenerated `.tap` tape-image codec, authored from the project DNA
2
+ // (dna/domain/file-formats.md "`.tap` — tape image (block stream)") and decided by
3
+ // the format conformance fixtures (dna/conformance/formats/tap-format.json). A `.tap`
4
+ // file is a flat concatenation of blocks with no header/footer/global length; each
5
+ // block is [len:2 LE][flag][data…][checksum], where len counts flag+data+checksum and
6
+ // checksum = XOR of the flag and every data byte (the 48K ROM tape parity byte). Tape,
7
+ // like a snapshot, is a file the machine loads, so this lives beside the .z80 codec.
8
+
9
+ // FMT-TAP-CHECKSUM-001: the XOR of the flag byte and every data byte.
10
+ export function tapChecksum(flag, data) {
11
+ let checksum = flag & 0xff;
12
+ for (let i = 0; i < data.length; i += 1) {
13
+ checksum ^= data[i] & 0xff;
14
+ }
15
+ return checksum & 0xff;
16
+ }
17
+
18
+ // FMT-TAP-BLOCK-001 / FMT-TAP-LENGTH-001 / FMT-TAP-CHECKSUM-001: walk the file front to
19
+ // back. Each block = a 2-byte little-endian length L (flag + data + checksum), then L
20
+ // body bytes: flag, L-2 data bytes, checksum. The recomputed XOR over flag+data must
21
+ // equal the stored checksum, else the block is corrupt. Throws on a truncated block.
22
+ export function parseTap(bytes) {
23
+ const file = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
24
+ const blocks = [];
25
+ let offset = 0;
26
+ while (offset < file.length) {
27
+ if (offset + 2 > file.length) {
28
+ throw new Error(`parseTap: truncated length prefix at offset ${offset}`);
29
+ }
30
+ const length = file[offset] | (file[offset + 1] << 8); // little-endian
31
+ const bodyStart = offset + 2;
32
+ const bodyEnd = bodyStart + length;
33
+ if (length < 2) {
34
+ throw new Error(`parseTap: block at offset ${offset} has length ${length} < 2 (no room for flag + checksum)`);
35
+ }
36
+ if (bodyEnd > file.length) {
37
+ throw new Error(`parseTap: block at offset ${offset} runs past end of file (need ${bodyEnd}, have ${file.length})`);
38
+ }
39
+ const flag = file[bodyStart];
40
+ const data = file.slice(bodyStart + 1, bodyEnd - 1); // L-2 data bytes
41
+ const checksum = file[bodyEnd - 1];
42
+ const computed = tapChecksum(flag, data);
43
+ if (computed !== checksum) {
44
+ throw new Error(`parseTap: block at offset ${offset} checksum mismatch (stored 0x${checksum.toString(16)}, computed 0x${computed.toString(16)})`);
45
+ }
46
+ blocks.push({ flag, data, checksum });
47
+ offset = bodyEnd;
48
+ }
49
+ return blocks;
50
+ }
51
+
52
+ // FMT-TAP-ROUNDTRIP-001: the exact inverse of parseTap. For each { flag, data }, emit
53
+ // [len LE][flag][data][checksum] with len = data.length + 2 and the checksum recomputed
54
+ // from flag+data; concatenate the blocks in order.
55
+ export function serializeTap(blocks) {
56
+ let total = 0;
57
+ for (const block of blocks) {
58
+ total += 2 + 2 + block.data.length; // length prefix + flag + checksum + data
59
+ }
60
+ const out = new Uint8Array(total);
61
+ let offset = 0;
62
+ for (const { flag, data } of blocks) {
63
+ const length = data.length + 2; // flag + data + checksum
64
+ out[offset] = length & 0xff; // little-endian low byte
65
+ out[offset + 1] = (length >> 8) & 0xff; // high byte
66
+ out[offset + 2] = flag & 0xff;
67
+ for (let i = 0; i < data.length; i += 1) {
68
+ out[offset + 3 + i] = data[i] & 0xff;
69
+ }
70
+ out[offset + 3 + data.length] = tapChecksum(flag, data);
71
+ offset += 2 + length;
72
+ }
73
+ return out;
74
+ }
@@ -0,0 +1,43 @@
1
+ export function createTapeDeck(pulses: any, { clock, startLevel, keyboard }?: {
2
+ startLevel?: number;
3
+ keyboard?: number;
4
+ }): {
5
+ levelAt: (t: any) => number;
6
+ total: number;
7
+ read(port: any): number;
8
+ write(port: any, value: any): void;
9
+ };
10
+ export function edgeLoadWithDeck(machine: any, deck: any, { ix, de, flag, load, tStateBudget, sentinel }?: {
11
+ load?: boolean;
12
+ tStateBudget?: number;
13
+ sentinel?: number;
14
+ }): {
15
+ ok: boolean;
16
+ reason: string;
17
+ bytesLoaded: number;
18
+ tStates: number;
19
+ };
20
+ export function edgeLoad(machine: any, pulses: any, { ix, de, flag, load, tStateBudget, sentinel, startLevel, keyboard, trailingPulse }?: {
21
+ load?: boolean;
22
+ tStateBudget?: number;
23
+ sentinel?: number;
24
+ startLevel?: number;
25
+ keyboard?: number;
26
+ trailingPulse?: number;
27
+ }): {
28
+ ok: boolean;
29
+ reason: string;
30
+ bytesLoaded: number;
31
+ tStates: number;
32
+ };
33
+ export function instantLoad(machine: any, body: any, { ix, de, flag, load }?: {
34
+ load?: boolean;
35
+ }): {
36
+ ok: boolean;
37
+ reason: string;
38
+ bytesLoaded: number;
39
+ tStates: number;
40
+ };
41
+ export const LD_BYTES_ENTRY: 1366;
42
+ export { FRAME_T_STATES };
43
+ import { FRAME_T_STATES } from "@zx-vibes/ula";
@@ -0,0 +1,221 @@
1
+ // Regenerated ROM tape edge-loading model, authored from the project DNA
2
+ // (dna/domain/tape-loading.md "Edge loading" + dna/domain/host-io-port-fe.md
3
+ // HOST-IO-PORTFE-READ-BITS-001) and decided by the tape edge-load conformance
4
+ // fixtures (dna/conformance/tape/edge-load.json). The opaque 48K ROM LD-BYTES
5
+ // routine (0x0556, memory-map.md MM-ROM-ARTIFACT-001 / ADR-0024) consumes the W10.9
6
+ // EAR pulse stream (tape-pulses.mjs blockToPulses) on port 0xFE bit 6 and loads a
7
+ // tape block byte-for-byte into RAM. This lives beside the .tap/.tzx codecs and the
8
+ // pulse encoder, since the same machine loads the tape.
9
+ import { FRAME_T_STATES } from "@zx-vibes/ula";
10
+
11
+ // The ROM tape-load entry point. The DNA references ONLY this address of the opaque
12
+ // ROM (ADR-0024); no other ROM routine is documented.
13
+ export const LD_BYTES_ENTRY = 0x0556;
14
+
15
+ // Z80 F-register carry bit (TAPE-EDGE-LDBYTES-001: CARRY set on entry = LOAD).
16
+ const CARRY = 0x01;
17
+
18
+ // A generous default ceiling so a wrong register setup or a broken deck can never
19
+ // hang: a full data block is ~7M T-states of leader; 50M T covers a large block with
20
+ // headroom. Exceeding the budget is a load failure, never a spin (risk guard).
21
+ const DEFAULT_T_STATE_BUDGET = 50_000_000;
22
+
23
+ // Stack the loader runs on (high RAM, clear of a 0x4000+ destination). LD-BYTES pushes
24
+ // its own SA/LD-RET return plus a couple of saves; this leaves ample room below.
25
+ const STACK_TOP = 0xff58;
26
+
27
+ // A return address the ROM never sets PC to during a load; pushed as the sentinel so
28
+ // the loader's final RET lands here and the run loop stops (TAPE-EDGE-LDBYTES-001).
29
+ const DEFAULT_SENTINEL = 0x7fff;
30
+
31
+ // TAPE-EDGE-TRAILING-001: a block's data pulses end on the second pulse of the
32
+ // checksum's last bit; the loader reads each bit as one full period (two edges), so the
33
+ // FINAL bit needs a closing edge AFTER the last pulse. On a real tape that edge is the
34
+ // leading transition of the inter-block pause (TZX 0x20). `edgeLoad` therefore appends one
35
+ // trailing pulse — a ~1 ms pause segment (3500 T at 3.5 MHz) — so the line transitions
36
+ // once after the block and the loader detects the end of the final bit. Its exact length
37
+ // is not load-bearing (any closing edge works); only that the transition occurs.
38
+ const TRAILING_EDGE_PULSE_T = 3500;
39
+
40
+ // TAPE-EDGE-DECK-001 / HOST-IO-PORTFE-READ-BITS-001: a "tape deck" implementing the
41
+ // machine `io` contract { read(port), write(port, value) }. On a port-0xFE read (ULA:
42
+ // address bit A0 = 0) it returns the keyboard/idle byte with bit 6 driven from the tape
43
+ // level at the current tape clock. The deck holds the pulse list and a MONOTONIC tape
44
+ // T-state cursor (supplied by `clock`); the level toggles at every pulse boundary from a
45
+ // fixed start convention. Once the cursor runs past the last pulse the tape is silent and
46
+ // bit 6 falls back to the issue-3 idle rule: it tracks the last bit-4 (speaker/EAR-out)
47
+ // written to port 0xFE.
48
+ export function createTapeDeck(pulses, { clock, startLevel = 0, keyboard = 0x1f } = {}) {
49
+ if (typeof clock !== "function") {
50
+ throw new Error("createTapeDeck: a clock() returning the tape T-state cursor is required");
51
+ }
52
+ const durations = pulses instanceof Uint16Array || pulses instanceof Array ? pulses : Array.from(pulses ?? []);
53
+ // ends[i] = cumulative T-state at which pulse i finishes; pulse i spans [ends[i-1], ends[i]).
54
+ const ends = new Array(durations.length);
55
+ let acc = 0;
56
+ for (let i = 0; i < durations.length; i += 1) {
57
+ acc += durations[i];
58
+ ends[i] = acc;
59
+ }
60
+ const total = acc;
61
+ const start = startLevel & 1;
62
+ // Issue-3 idle level (the EAR-in level when the tape is not driving the line): the last
63
+ // value written to bit 4 of port 0xFE. Initialized to the tape's start level.
64
+ let idleLevel = start;
65
+
66
+ // The tape-in level at tape clock `t`. Within the stream the level is the start level
67
+ // XOR the parity of the pulse index; past the end it is the issue-3 idle level.
68
+ function levelAt(t) {
69
+ if (durations.length === 0 || t >= total) return idleLevel;
70
+ // Smallest i with ends[i] > t — the index of the pulse covering t.
71
+ let lo = 0;
72
+ let hi = ends.length - 1;
73
+ while (lo < hi) {
74
+ const mid = (lo + hi) >> 1;
75
+ if (ends[mid] > t) hi = mid;
76
+ else lo = mid + 1;
77
+ }
78
+ return (start ^ (lo & 1)) & 1;
79
+ }
80
+
81
+ return {
82
+ levelAt,
83
+ total,
84
+ read(port) {
85
+ // Only the ULA (even port, A0 = 0) answers; other ports float high.
86
+ if ((port & 0x01) !== 0) return 0xff;
87
+ const level = levelAt(clock());
88
+ // HOST-IO-PORTFE-READ-BITS-001: b0–b4 keyboard (1 = released), b5/b7 = 1 (unused),
89
+ // b6 = EAR/tape-in level.
90
+ return ((keyboard & 0x1f) | 0xa0 | (level ? 0x40 : 0x00)) & 0xff;
91
+ },
92
+ write(port, value) {
93
+ // Issue-3 idle rule: bit 6 idles at the last bit-4 (EAR-out) written to port 0xFE.
94
+ if ((port & 0x01) === 0) idleLevel = (value >> 4) & 1;
95
+ },
96
+ };
97
+ }
98
+
99
+ // TAPE-EDGE-LDBYTES-001: set up the LD-BYTES register contract on `machine`, push the
100
+ // sentinel return, and run the machine instruction-by-instruction until PC returns to the
101
+ // sentinel (success/failure) or the T-state budget is hit. `deck` (already the machine's
102
+ // io) supplies the tape edge stream. Returns { ok, reason, bytesLoaded, tStates }; ok is
103
+ // the CARRY flag the ROM reports (set = the block loaded and its checksum verified).
104
+ export function edgeLoadWithDeck(machine, deck, { ix, de, flag, load = true, tStateBudget = DEFAULT_T_STATE_BUDGET, sentinel = DEFAULT_SENTINEL } = {}) {
105
+ if (typeof ix !== "number" || typeof de !== "number" || typeof flag !== "number") {
106
+ throw new Error("edgeLoadWithDeck: ix, de and flag are required numbers");
107
+ }
108
+ machine.io = deck;
109
+ const reg = machine.registers;
110
+ // Entry contract: A = expected flag byte, CARRY set = LOAD (reset = VERIFY). LD-BYTES
111
+ // moves the entry AF into AF' (EX AF,AF'); set both so the flag/carry reach AF'
112
+ // regardless of the exchange.
113
+ const entryF = load ? CARRY : 0;
114
+ reg.a = flag & 0xff;
115
+ reg.f = entryF;
116
+ reg.a_ = flag & 0xff;
117
+ reg.f_ = entryF;
118
+ // IX = destination, DE = data byte count.
119
+ reg.ixh = (ix >> 8) & 0xff;
120
+ reg.ixl = ix & 0xff;
121
+ reg.d = (de >> 8) & 0xff;
122
+ reg.e = de & 0xff;
123
+ // The ROM disables interrupts itself (DI); keep them off so the direct call is clean.
124
+ reg.iff1 = 0;
125
+ reg.iff2 = 0;
126
+ // Push the sentinel return address, then enter LD-BYTES.
127
+ machine.memory[(STACK_TOP - 1) & 0xffff] = (sentinel >> 8) & 0xff;
128
+ machine.memory[(STACK_TOP - 2) & 0xffff] = sentinel & 0xff;
129
+ reg.sp = (STACK_TOP - 2) & 0xffff;
130
+ reg.pc = LD_BYTES_ENTRY;
131
+
132
+ const startRun = machine.tStatesTotal;
133
+ while ((machine.registers.pc & 0xffff) !== (sentinel & 0xffff)) {
134
+ if (machine.tStatesTotal - startRun > tStateBudget) {
135
+ return { ok: false, reason: "budget", bytesLoaded: 0, tStates: machine.tStatesTotal - startRun };
136
+ }
137
+ machine.stepInstruction();
138
+ }
139
+ const ok = Boolean(machine.registers.f & CARRY);
140
+ const deLeft = ((machine.registers.d & 0xff) << 8) | (machine.registers.e & 0xff);
141
+ return {
142
+ ok,
143
+ reason: ok ? "ok" : "load-error",
144
+ bytesLoaded: (de - deLeft) & 0xffff,
145
+ tStates: machine.tStatesTotal - startRun,
146
+ };
147
+ }
148
+
149
+ // TAPE-EDGE-LOAD-001: the convenience entry. Build the standard tape deck (a MONOTONIC
150
+ // tape cursor that advances with the machine's executed T-states — not a frame-modulo
151
+ // position, which would lose edges across a frame wrap) over `pulses`, wire it as the
152
+ // machine's io, and drive LD-BYTES. `pulses` is the EAR pulse stream of a single block
153
+ // body (blockToPulses); `de` is the data byte count (the body length minus the flag and
154
+ // checksum), `flag` the block's flag byte, `ix` the RAM destination.
155
+ export function edgeLoad(machine, pulses, { ix, de, flag, load = true, tStateBudget = DEFAULT_T_STATE_BUDGET, sentinel = DEFAULT_SENTINEL, startLevel = 0, keyboard = 0x1f, trailingPulse = TRAILING_EDGE_PULSE_T } = {}) {
156
+ const startT = machine.tStatesTotal;
157
+ // Append the closing edge (TAPE-EDGE-TRAILING-001) so the final bit terminates.
158
+ const base = pulses instanceof Array ? pulses : Array.from(pulses ?? []);
159
+ const playable = trailingPulse ? [...base, trailingPulse] : base;
160
+ const deck = createTapeDeck(playable, {
161
+ clock: () => machine.tStatesTotal - startT,
162
+ startLevel,
163
+ keyboard,
164
+ });
165
+ return edgeLoadWithDeck(machine, deck, { ix, de, flag, load, tStateBudget, sentinel });
166
+ }
167
+
168
+ // TAPE-INSTANT-LOAD-001: the instant (a.k.a. trap / flash) loader. A consumer convenience
169
+ // that reproduces the OBSERVABLE result of `edgeLoad` (the real ROM `LD-BYTES`) for the same
170
+ // tape block WITHOUT executing the ROM or simulating the pulse stream — it traps the load and
171
+ // writes the block's data bytes straight to RAM. Its whole correctness criterion is
172
+ // `instant == edge` for the same `.tap` block (TAPE-INSTANT-EQUIV-001): a mutual cross-check
173
+ // against the real ROM, fabrication-free. `body` is the full block body
174
+ // `[flag, ...data, checksum]` (the same input `blockToPulses` takes); `ix`/`de`/`flag` are
175
+ // the LD-BYTES register contract (TAPE-EDGE-LDBYTES-001): IX = destination, DE = data byte
176
+ // count, A = expected flag. Returns `{ ok, reason, bytesLoaded, tStates }` with `tStates = 0`
177
+ // (instant — no machine time elapses). Only the OBSERVABLE triplet — `ok` (the CARRY result),
178
+ // `bytesLoaded`, and the bytes written to RAM — is the contract and matches `edgeLoad`; the
179
+ // `reason` string is the instant loader's own diagnosis and is NOT part of the contract (the
180
+ // real ROM may instead time out or report a generic load error for the same failure).
181
+ export function instantLoad(machine, body, { ix, de, flag, load = true } = {}) {
182
+ if (typeof ix !== "number" || typeof de !== "number" || typeof flag !== "number") {
183
+ throw new Error("instantLoad: ix, de and flag are required numbers");
184
+ }
185
+ const bytes = body instanceof Uint8Array || Array.isArray(body) ? body : Array.from(body ?? []);
186
+ const expectedFlag = flag & 0xff;
187
+ const dest = ix & 0xffff;
188
+ const want = de & 0xffff;
189
+ // The block body is [flag][data…][checksum]; a body shorter than 2 bytes has no checksum.
190
+ if (bytes.length < 2) {
191
+ return { ok: false, reason: "empty", bytesLoaded: 0, tStates: 0 };
192
+ }
193
+ const blockFlag = bytes[0] & 0xff;
194
+ // TAPE-INSTANT-FLAG-001: LD-BYTES compares the flag byte BEFORE storing any data; on a flag
195
+ // mismatch it returns failure with NOTHING written (verified against the real ROM: a
196
+ // mismatched edge-load leaves the destination RAM untouched and returns carry reset).
197
+ if (blockFlag !== expectedFlag) {
198
+ return { ok: false, reason: "flag-mismatch", bytesLoaded: 0, tStates: 0 };
199
+ }
200
+ // Data bytes present in the block body (everything between the flag and the final checksum).
201
+ const dataAvailable = bytes.length - 2;
202
+ const n = Math.min(want, Math.max(0, dataAvailable));
203
+ // TAPE-INSTANT-LOAD-001 / TAPE-INSTANT-CHECKSUM-001: store the DE data bytes (the flag and
204
+ // checksum are NOT stored, TAPE-EDGE-LDBYTES-001) while accumulating the running XOR parity
205
+ // over the flag + the data bytes + the checksum byte read after them
206
+ // (file-formats.md FMT-TAP-CHECKSUM-001). CARRY/ok is set iff the parity is zero.
207
+ let parity = blockFlag;
208
+ for (let i = 0; i < n; i += 1) {
209
+ const value = bytes[1 + i] & 0xff;
210
+ if (load) machine.memory[(dest + i) & 0xffff] = value;
211
+ parity ^= value;
212
+ }
213
+ // The byte LD-BYTES reads as the checksum after exactly DE data bytes.
214
+ parity ^= bytes[1 + want] & 0xff;
215
+ const ok = parity === 0;
216
+ return { ok, reason: ok ? "ok" : "checksum-error", bytesLoaded: n, tStates: 0 };
217
+ }
218
+
219
+ // Re-exported so a regeneration / self-test can build a frame-modulo (broken) tape clock
220
+ // to prove the monotonic cursor is load-bearing (TAPE-EDGE-DECK-001).
221
+ export { FRAME_T_STATES };
@@ -0,0 +1,9 @@
1
+ export function bytePulses(byte: any): number[];
2
+ export function blockToPulses(bytes: any): any[];
3
+ export const PILOT_PULSE_T: 2168;
4
+ export const PILOT_PULSES_HEADER: 8063;
5
+ export const PILOT_PULSES_DATA: 3223;
6
+ export const SYNC1_T: 667;
7
+ export const SYNC2_T: 735;
8
+ export const BIT0_PULSE_T: 855;
9
+ export const BIT1_PULSE_T: 1710;
@@ -0,0 +1,60 @@
1
+ // Regenerated ROM tape-encoding model, authored from the project DNA
2
+ // (dna/domain/tape-loading.md "The EAR pulse stream") and decided by the tape
3
+ // conformance fixtures (dna/conformance/tape/tape-pulses.json). A tape block body
4
+ // ([flag][data…][checksum]) is transmitted as a sequence of EAR pulses (durations in
5
+ // Z80 T-states; the tape-in line, port 0xFE bit 6, toggles each pulse): a pilot tone,
6
+ // a sync pair, then each byte MSB-first as two pulses per bit. Same machine loads the
7
+ // tape, so this lives beside the .tap/.tzx codecs.
8
+
9
+ // TAPE-PULSE-TIMINGS-001 / TAPE-PULSE-PILOT-001: fixed pulse durations (T-states at
10
+ // 3.5 MHz), identical to the TZX v1.20 turbo-block defaults.
11
+ export const PILOT_PULSE_T = 2168;
12
+ export const PILOT_PULSES_HEADER = 8063; // flag < 0x80 (~5 s leader)
13
+ export const PILOT_PULSES_DATA = 3223; // flag >= 0x80 (~2 s leader)
14
+ export const SYNC1_T = 667;
15
+ export const SYNC2_T = 735;
16
+ export const BIT0_PULSE_T = 855;
17
+ export const BIT1_PULSE_T = 1710;
18
+
19
+ const HEADER_FLAG_MAX = 0x80; // flag < 0x80 => header pilot length
20
+
21
+ // TAPE-PULSE-DATA-001: one byte -> 16 pulses, most-significant bit first; a 0 bit is
22
+ // two 855 T pulses, a 1 bit is two 1710 T pulses.
23
+ export function bytePulses(byte) {
24
+ const pulses = [];
25
+ for (let bit = 7; bit >= 0; bit -= 1) {
26
+ const length = (byte >> bit) & 1 ? BIT1_PULSE_T : BIT0_PULSE_T;
27
+ pulses.push(length, length);
28
+ }
29
+ return pulses;
30
+ }
31
+
32
+ // TAPE-PULSE-BLOCK-001: full pulse list for a block body. `bytes` is the on-tape body
33
+ // [flag, ...data, checksum]. Pilot count is chosen by the flag (bytes[0]); then sync1,
34
+ // sync2, then each byte's data pulses.
35
+ export function blockToPulses(bytes) {
36
+ const body = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
37
+ if (body.length === 0) {
38
+ throw new Error("blockToPulses: empty block body (need at least a flag byte)");
39
+ }
40
+ const flag = body[0] & 0xff;
41
+ const pilotCount = flag < HEADER_FLAG_MAX ? PILOT_PULSES_HEADER : PILOT_PULSES_DATA;
42
+
43
+ const pulses = new Array(pilotCount + 2 + body.length * 16);
44
+ let i = 0;
45
+ // TAPE-PULSE-PILOT-001: pilot tone.
46
+ for (let p = 0; p < pilotCount; p += 1) pulses[i++] = PILOT_PULSE_T;
47
+ // TAPE-PULSE-SYNC-001: the sync pair, in order.
48
+ pulses[i++] = SYNC1_T;
49
+ pulses[i++] = SYNC2_T;
50
+ // TAPE-PULSE-DATA-001: every body byte, MSB first.
51
+ for (let b = 0; b < body.length; b += 1) {
52
+ const byte = body[b] & 0xff;
53
+ for (let bit = 7; bit >= 0; bit -= 1) {
54
+ const length = (byte >> bit) & 1 ? BIT1_PULSE_T : BIT0_PULSE_T;
55
+ pulses[i++] = length;
56
+ pulses[i++] = length;
57
+ }
58
+ }
59
+ return pulses;
60
+ }
@@ -0,0 +1,13 @@
1
+ export function parseTzx(bytes: any): {
2
+ version: {
3
+ major: number;
4
+ minor: number;
5
+ };
6
+ blocks: any[];
7
+ };
8
+ export function serializeTzx(tzx: any): Uint8Array<ArrayBuffer>;
9
+ export const TZX_SIGNATURE: "ZXTape!";
10
+ export namespace TZX_VERSION {
11
+ let major: number;
12
+ let minor: number;
13
+ }