@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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +61 -0
- package/src/index.d.mts +8 -0
- package/src/index.mjs +79 -0
- package/src/interrupt.d.mts +26 -0
- package/src/interrupt.mjs +111 -0
- package/src/machine.d.mts +82 -0
- package/src/machine.mjs +242 -0
- package/src/snapshot-z80.d.mts +39 -0
- package/src/snapshot-z80.mjs +164 -0
- package/src/tap-format.d.mts +7 -0
- package/src/tap-format.mjs +74 -0
- package/src/tape-edge-load.d.mts +43 -0
- package/src/tape-edge-load.mjs +221 -0
- package/src/tape-pulses.d.mts +9 -0
- package/src/tape-pulses.mjs +60 -0
- package/src/tzx-format.d.mts +13 -0
- package/src/tzx-format.mjs +268 -0
|
@@ -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,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
|
+
}
|