@fugloy/emu 0.0.0-stage → 0.3.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/src/index.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @fugloy/emu — headless, deterministic TRS-80 Model III and Model I emulator core.
3
+ * Extracted in emu phase 0 from cschweda/trs80-emulator at 11612849 (v1.6.0); see NOTICE.md.
4
+ */
5
+ export { Machine } from './machine/machine.js';
6
+ export { Debugger, COVERAGE } from './debug/debugger.js';
7
+ export { LABEL_FILE_VERSION, COVERAGE_FIELDS, encodeRle, decodeRle, parseLabelFile, mergeLabelFiles } from './debug/label-file.js';
8
+ export { ROM_HASHES } from './machine/rom-hashes.js';
9
+ export { clockHz } from './machine/models.js';
10
+ export { TRS80System, CPU_CLOCK_HZ, RTC_HZ, RTC_INTERVAL_TSTATES } from './machine/trs80-system.js';
11
+ export { Z80CPU } from './cpu/z80cpu.js';
12
+ export { MemorySystem } from './machine/memory.js';
13
+ export { IOSystem, RTC_IRQ_BIT, CASSETTE_RISE_IRQ_BIT, CASSETTE_FALL_IRQ_BIT, NMI_INTRQ_BIT, MODE_32COL_BIT } from './machine/io.js';
14
+ export { KeyboardMatrix } from './devices/keyboard.js';
15
+ export { CassetteSystem } from './devices/cassette.js';
16
+ export { casToPulses, pulsesToBytes, ensureLeader } from './devices/cassette-tape.js';
17
+ export { FDC1793, parseWriteTrackStream } from './devices/fdc-wd1793.js';
18
+ export { DiskImage, JV1_MODEL1_SD_TRACKS } from './devices/disk-image.js';
19
+ export { Printer } from './devices/printer.js';
20
+ export { parseCas, fastLoadBasic, fastLoadSystem } from './loaders/cas-format.js';
21
+ export { parseBas } from './loaders/bas.js';
22
+ export { parseCmd, fastLoadCmd } from './loaders/cmd-format.js';
23
+ export { STATE_VERSION, STATE_MACHINE, toBase64, fromBase64, serializeState, restoreState } from './debug/state.js';
@@ -0,0 +1,107 @@
1
+ /**
2
+ * .BAS loader. A first byte of $FF means a tokenized program: per line a 2-byte next pointer, a
3
+ * 2-byte line number, the body, and 00, with two zero bytes ending the program. Otherwise the
4
+ * file is ASCII source, which the Machine feeds through the ROM keyboard hook.
5
+ *
6
+ * The pointers in a tokenized file are absolute from the machine that saved it, so they are
7
+ * rebuilt for the load address. A line body may hold 00 (machine code in a REM), so the chain is
8
+ * followed by pointer when it is self-consistent and by 00-scan when it is not.
9
+ */
10
+ import { writeTokenizedProgram } from './cas-format.js';
11
+
12
+ const hex = (n) => `$${n.toString(16).toUpperCase()}`;
13
+
14
+ /** Follows the pointer chain with line 1 assumed at `base`; null when it does not hold together. */
15
+ function walk(bytes, start, base) {
16
+ const n = bytes.length;
17
+ const lines = [];
18
+ let off = start;
19
+ let addr = base;
20
+ for (;;) {
21
+ if (off + 2 > n) return null;
22
+ const ptr = bytes[off] | (bytes[off + 1] << 8);
23
+ if (ptr === 0) return lines;
24
+ const len = ptr - addr;
25
+ if (len < 5 || off + len > n || bytes[off + len - 1] !== 0) return null;
26
+ lines.push({ lineNo: bytes[off + 2] | (bytes[off + 3] << 8), tokens: bytes.slice(off + 4, off + len - 1) });
27
+ addr = ptr;
28
+ off += len;
29
+ }
30
+ }
31
+
32
+ /** Splits at each 00, ignoring the pointers; throws when the program runs off the end of the file. */
33
+ function scan(bytes, start) {
34
+ const n = bytes.length;
35
+ const lines = [];
36
+ let off = start;
37
+ for (;;) {
38
+ if (off + 2 > n) throw new Error(`truncated .BAS: no 00 00 program terminator after ${lines.length} lines`);
39
+ if (bytes[off] === 0 && bytes[off + 1] === 0) return lines;
40
+ if (off + 4 > n) throw new Error(`truncated .BAS: line header cut off at offset ${off}`);
41
+ const end = bytes.indexOf(0, off + 4);
42
+ if (end < 0) throw new Error(`truncated .BAS: line at offset ${off} has no 00 terminator`);
43
+ lines.push({ lineNo: bytes[off + 2] | (bytes[off + 3] << 8), tokens: bytes.slice(off + 4, end) });
44
+ off = end + 1;
45
+ }
46
+ }
47
+
48
+ function parseTokenized(bytes) {
49
+ const start = 1;
50
+ if (bytes.length - start < 2) throw new Error('truncated .BAS: no program after the $FF header');
51
+ const ptr1 = bytes[start] | (bytes[start + 1] << 8);
52
+ if (ptr1 === 0) return { kind: 'tokenized', lines: [] };
53
+ // Line 1's base is unknown: try the stock TXTTAB, then each 00 in line 1 as its end.
54
+ const bases = [0x42e9];
55
+ for (let j = start + 4; j < bytes.length; j++) {
56
+ if (bytes[j] === 0) {
57
+ bases.push(ptr1 - (j + 1 - start));
58
+ if (bases.length > 64) break;
59
+ }
60
+ }
61
+ for (const base of bases) {
62
+ const lines = walk(bytes, start, base);
63
+ if (lines) return { kind: 'tokenized', lines };
64
+ }
65
+ // No pointer chain holds together (a bad next pointer, say): fall back to splitting at each 00,
66
+ // which throws "truncated" only when the program runs off the end of the file.
67
+ return { kind: 'tokenized', lines: scan(bytes, start) };
68
+ }
69
+
70
+ /**
71
+ * Parse a .BAS file.
72
+ * @param {Uint8Array} bytes
73
+ * @returns {{ kind: 'tokenized', lines: { lineNo: number, tokens: Uint8Array }[] } | { kind: 'ascii', text: string }}
74
+ */
75
+ export function parseBas(bytes) {
76
+ if (!bytes || bytes.length === 0) throw new Error('not a .BAS file: empty');
77
+ if (bytes[0] === 0xff) return parseTokenized(bytes);
78
+ let text = '';
79
+ for (let i = 0; i < bytes.length; i++) {
80
+ const b = bytes[i];
81
+ if (b === 0) throw new Error(`not a .BAS file: NUL at offset ${i} (looks like tokenized BASIC without the $FF header; add a leading $FF byte to load it)`);
82
+ if (!((b >= 0x20 && b <= 0x7e) || b === 0x0d || b === 0x0a || b === 0x09)) {
83
+ throw new Error(`not a .BAS file: byte ${hex(b)} at offset ${i} is neither tokenized ($FF first) nor ASCII text`);
84
+ }
85
+ text += String.fromCharCode(b);
86
+ }
87
+ text = text.replace(/\r\n?/g, '\n');
88
+ if (!text.endsWith('\n')) text += '\n';
89
+ return { kind: 'ascii', text };
90
+ }
91
+
92
+ /**
93
+ * Load a .BAS file into a Machine. Tokenized: write at TXTTAB and fix the pointers. ASCII: feed
94
+ * the text through the keyboard hook, which BASIC reads as the machine runs.
95
+ * @param {import('../machine/machine.js').Machine} machine
96
+ * @param {Uint8Array} bytes
97
+ * @returns {{ format: 'bas', kind: 'tokenized' | 'ascii', start: number, end: number | null, blocks: { addr: number, len: number, why: string }[] }}
98
+ */
99
+ export function loadBas(machine, bytes) {
100
+ const parsed = parseBas(bytes);
101
+ if (parsed.kind === 'ascii') {
102
+ machine.keyboard.feed(parsed.text);
103
+ return { format: 'bas', kind: 'ascii', start: machine.system.memory.readWord(0x40a4), end: null, blocks: [] };
104
+ }
105
+ const { start, end } = writeTokenizedProgram(machine.system, parsed.lines);
106
+ return { format: 'bas', kind: 'tokenized', start, end, blocks: [{ addr: start, len: end - start, why: 'BASIC program' }] };
107
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * TRS-80 .cas cassette image handling
3
+ *
4
+ * A .cas file is the decoded byte stream of a tape. Two layouts matter:
5
+ *
6
+ * BASIC (CSAVE): [leader 0x00/0x55 ...] 0xA5 0xD3 0xD3 0xD3 name(1)
7
+ * then the tokenized program image: per line
8
+ * [link u16][line# u16][tokens...][0x00], ending with a
9
+ * 0x0000 link. Link pointers are absolute addresses from
10
+ * the saving machine — they are recomputed on load.
11
+ *
12
+ * SYSTEM: [leader] 0xA5 0x55 name(6) then blocks:
13
+ * 0x3C len(0=256) addr(u16) data[len] checksum
14
+ * terminated by 0x78 entry(u16).
15
+ *
16
+ * Fast-loading bypasses the cassette port entirely: BASIC programs are
17
+ * written at TXTTAB with fresh links and the variable pointers fixed up
18
+ * (what CLOAD leaves behind); SYSTEM blocks are written to their target
19
+ * addresses and execution jumps to the entry point (what SYSTEM's "/"
20
+ * does).
21
+ */
22
+
23
+ import { startProgram } from "./handoff.js";
24
+
25
+ // Model III cassette BASIC work area
26
+ const TXTTAB_PTR = 0x40a4; // -> start of BASIC program (0x42E9 stock)
27
+ const VARTAB_PTR = 0x40f9; // simple variables (= end of program)
28
+ const ARYTAB_PTR = 0x40fb; // arrays
29
+ const FRETOP_PTR = 0x40fd; // free space
30
+
31
+ /**
32
+ * Parse a .cas byte stream.
33
+ * @returns {{kind:"basic",name:string,lines:{lineNo:number,tokens:number[]}[]}
34
+ * | {kind:"system",name:string,blocks:{addr:number,data:Uint8Array}[],entry:number,checksumErrors:number}}
35
+ */
36
+ export function parseCas(bytes) {
37
+ if (!bytes || bytes.length < 4) {
38
+ throw new Error("Not a .cas image: too short");
39
+ }
40
+
41
+ // Skip the leader (0x00 or 0x55 bytes) up to the sync byte — 0xA5 on
42
+ // 500-baud tapes, 0x7F on Model III 1500-baud ones. Some images omit
43
+ // the leader or even the sync.
44
+ let i = 0;
45
+ while (i < bytes.length && (bytes[i] === 0x00 || bytes[i] === 0x55)) i++;
46
+ if (bytes[i] === 0xa5 || bytes[i] === 0x7f) {
47
+ i++;
48
+ } else {
49
+ i = 0; // no leader/sync: start over at the raw content
50
+ }
51
+
52
+ if (bytes[i] === 0xd3 && bytes[i + 1] === 0xd3 && bytes[i + 2] === 0xd3) {
53
+ return parseBasic(bytes, i + 3);
54
+ }
55
+ if (bytes[i] === 0x55) {
56
+ return parseSystem(bytes, i + 1);
57
+ }
58
+ // A few BASIC images begin at the D3s without sync handled above
59
+ throw new Error(
60
+ "Unrecognized .cas layout (expected BASIC 0xD3 0xD3 0xD3 or SYSTEM 0x55 header)"
61
+ );
62
+ }
63
+
64
+ // Stock cassette BASIC program base (Model I and III alike): the link
65
+ // addresses a real CSAVE writes are absolute from here.
66
+ const TXTTAB_STOCK = 0x42e9;
67
+
68
+ function parseBasic(bytes, offset) {
69
+ const name = String.fromCharCode(bytes[offset]);
70
+ let i = offset + 1;
71
+ const lines = [];
72
+
73
+ // Real CLOAD walks the link-pointer chain, which is what lets a line
74
+ // contain 0x00 bytes (machine code embedded in a REM — the
75
+ // Christopherson games). Trust each link while it stays
76
+ // self-consistent; fall back to 0x00-scanning for that record when it
77
+ // doesn't (some tools write garbage links). After a fallback the next
78
+ // record re-syncs on the link value, so lengths derive from link
79
+ // deltas and the saving machine's base stops mattering.
80
+ let addr = TXTTAB_STOCK;
81
+ while (i + 1 < bytes.length) {
82
+ const link = bytes[i] | (bytes[i + 1] << 8);
83
+ if (link === 0x0000) {
84
+ break; // program terminator
85
+ }
86
+ const lineNo = bytes[i + 2] | (bytes[i + 3] << 8);
87
+ const recLen = link - addr; // [link u16][line# u16][tokens...][0x00]
88
+ if (
89
+ recLen >= 5 &&
90
+ recLen <= 1000 &&
91
+ i + recLen < bytes.length &&
92
+ bytes[i + recLen - 1] === 0x00
93
+ ) {
94
+ lines.push({
95
+ lineNo,
96
+ tokens: Array.from(bytes.slice(i + 4, i + recLen - 1)),
97
+ });
98
+ i += recLen;
99
+ } else {
100
+ const tokens = [];
101
+ let j = i + 4;
102
+ while (j < bytes.length && bytes[j] !== 0x00) {
103
+ tokens.push(bytes[j]);
104
+ j++;
105
+ }
106
+ j++; // consume the end-of-line 0x00
107
+ lines.push({ lineNo, tokens });
108
+ i = j;
109
+ }
110
+ addr = link; // re-sync even after a fallback record
111
+ }
112
+
113
+ return { kind: "basic", name, lines };
114
+ }
115
+
116
+ function parseSystem(bytes, offset) {
117
+ const name = String.fromCharCode(...bytes.slice(offset, offset + 6))
118
+ .replace(/\0/g, " ")
119
+ .trimEnd();
120
+ let i = offset + 6;
121
+ const blocks = [];
122
+ let entry = null;
123
+ let checksumErrors = 0;
124
+
125
+ while (i < bytes.length) {
126
+ const marker = bytes[i++];
127
+ if (marker === 0x3c) {
128
+ let len = bytes[i++];
129
+ if (len === 0) len = 256;
130
+ const addr = bytes[i] | (bytes[i + 1] << 8);
131
+ i += 2;
132
+ const data = bytes.slice(i, i + len);
133
+ i += len;
134
+ const checksum = bytes[i++];
135
+ let sum = (addr & 0xff) + ((addr >> 8) & 0xff);
136
+ for (const b of data) sum += b;
137
+ if ((sum & 0xff) !== checksum) checksumErrors++;
138
+ blocks.push({ addr, data: new Uint8Array(data) });
139
+ } else if (marker === 0x78) {
140
+ entry = bytes[i] | (bytes[i + 1] << 8);
141
+ break;
142
+ } else if (marker === 0x00 || marker === 0x55) {
143
+ // stray leader/padding between blocks on some tapes
144
+ continue;
145
+ } else {
146
+ throw new Error(
147
+ `Unexpected SYSTEM block marker 0x${marker.toString(16)} at ${i - 1}`
148
+ );
149
+ }
150
+ }
151
+
152
+ if (entry === null) {
153
+ throw new Error("SYSTEM tape has no entry-point (0x78) block");
154
+ }
155
+ return { kind: "system", name, blocks, entry, checksumErrors };
156
+ }
157
+
158
+ /**
159
+ * Write tokenized BASIC lines at TXTTAB with freshly computed next-line pointers, end the program
160
+ * with 00 00, and set the variable-area pointers ($40F9, $40FB, $40FD) as CLOAD leaves them.
161
+ * Shared by fastLoadBasic and the .BAS loader.
162
+ * @param {{ memory: any }} system
163
+ * @param {{ lineNo: number, tokens: ArrayLike<number> }[]} lines
164
+ * @returns {{ start: number, end: number }} TXTTAB, and the address one past the terminator
165
+ */
166
+ export function writeTokenizedProgram(system, lines) {
167
+ const memory = system.memory;
168
+ const start = memory.readWord(TXTTAB_PTR);
169
+ let addr = start;
170
+
171
+ for (const line of lines) {
172
+ const next = addr + 2 + 2 + line.tokens.length + 1;
173
+ memory.writeWord(addr, next);
174
+ memory.writeWord(addr + 2, line.lineNo);
175
+ for (let i = 0; i < line.tokens.length; i++) {
176
+ memory.writeByte(addr + 4 + i, line.tokens[i]);
177
+ }
178
+ memory.writeByte(next - 1, 0x00);
179
+ addr = next;
180
+ }
181
+
182
+ memory.writeWord(addr, 0x0000); // program terminator
183
+ addr += 2;
184
+
185
+ memory.writeWord(VARTAB_PTR, addr);
186
+ memory.writeWord(ARYTAB_PTR, addr);
187
+ memory.writeWord(FRETOP_PTR, addr);
188
+ return { start, end: addr };
189
+ }
190
+
191
+ /**
192
+ * Fast-load a parsed BASIC program: write it at TXTTAB with recomputed
193
+ * line links and set the variable-area pointers, exactly the state CLOAD
194
+ * leaves. The machine should be sitting at READY. Caller typically
195
+ * follows with system.typeText("RUN\n").
196
+ * @returns {number} address one past the program terminator
197
+ */
198
+ export function fastLoadBasic(system, parsed) {
199
+ return writeTokenizedProgram(system, parsed.lines).end;
200
+ }
201
+
202
+ /**
203
+ * Fast-load a parsed SYSTEM (machine-code) tape: write every block and
204
+ * jump to the entry point — the same transfer SYSTEM's "/" performs.
205
+ */
206
+ export function fastLoadSystem(system, parsed) {
207
+ for (const block of parsed.blocks) {
208
+ for (let i = 0; i < block.data.length; i++) {
209
+ system.memory.writeByte((block.addr + i) & 0xffff, block.data[i]);
210
+ }
211
+ }
212
+ return startProgram(system, parsed.entry);
213
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * TRS-80 DOS /CMD executable handling
3
+ *
4
+ * A /CMD file is a stream of records: [type][length][payload…]. The
5
+ * types honored here:
6
+ *
7
+ * 0x01 object-code block payload: addr.lo addr.hi data…
8
+ * For this type the length byte counts the
9
+ * address AND data bytes, with the quirk
10
+ * that 0, 1 and 2 mean 256, 257 and 258
11
+ * (a block always carries the 2 address
12
+ * bytes plus at least one data byte).
13
+ * 0x02 transfer address payload: entry.lo entry.hi — ends the load
14
+ * 0x05 module name payload: ASCII name
15
+ * other skipped by their length byte (comments,
16
+ * patch and directory records)
17
+ *
18
+ * Record 07 is a patch name header: its payload is the patch's name in
19
+ * ASCII, and the patch's code follows it as ordinary 01 blocks, which
20
+ * load like any other; a patch that has been YANKed has those blocks
21
+ * retyped 10, which the loader ignores (LDOS 5.1 manual, Tech Info:
22
+ * File Formats p. 1, item c; Soltoff, The Programmer's Guide to
23
+ * LDOS/TRSDOS Version 6, p. A-149, figure A-1). So skipping 07 and 10
24
+ * by their length is the whole of their handling.
25
+ *
26
+ * Fast-loading writes the blocks straight into RAM and jumps to the
27
+ * entry point — the same hand-off TRSDOS performs — so tape/disk-era
28
+ * machine-language games run with no DOS resident.
29
+ */
30
+
31
+ import { startProgram } from "./handoff.js";
32
+
33
+ /**
34
+ * Parse a /CMD byte stream.
35
+ * @returns {{name:string, blocks:{addr:number,data:Uint8Array}[], entry:number}}
36
+ */
37
+ export function parseCmd(bytes) {
38
+ if (!bytes || bytes.length < 4) {
39
+ throw new Error("Not a /CMD image: too short");
40
+ }
41
+ const blocks = [];
42
+ let entry = null;
43
+ let name = "";
44
+ let i = 0;
45
+
46
+ while (i + 1 < bytes.length) {
47
+ const type = bytes[i++];
48
+ let len = bytes[i++];
49
+ if (type === 0x01) {
50
+ if (len < 3) len += 256; // 0,1,2 mean 256,257,258
51
+ if (i + len > bytes.length) {
52
+ throw new Error("Truncated /CMD load block");
53
+ }
54
+ const addr = bytes[i] | (bytes[i + 1] << 8);
55
+ blocks.push({ addr, data: new Uint8Array(bytes.slice(i + 2, i + len)) });
56
+ i += len;
57
+ } else if (type === 0x02) {
58
+ entry = bytes[i] | (bytes[i + 1] << 8);
59
+ break; // transfer address ends the load; trailing bytes are ignored
60
+ } else if (type === 0x05) {
61
+ name = String.fromCharCode(...bytes.slice(i, i + len)).trimEnd();
62
+ i += len;
63
+ } else {
64
+ i += len; // unknown record: skip its payload
65
+ }
66
+ }
67
+
68
+ if (entry === null) {
69
+ throw new Error("/CMD image has no transfer-address (0x02) record");
70
+ }
71
+ if (blocks.length === 0) {
72
+ throw new Error("/CMD image has no load blocks");
73
+ }
74
+ return { name, blocks, entry };
75
+ }
76
+
77
+ /**
78
+ * Fast-load a parsed /CMD: write every block and jump to the entry point.
79
+ */
80
+ export function fastLoadCmd(system, parsed) {
81
+ for (const block of parsed.blocks) {
82
+ for (let i = 0; i < block.data.length; i++) {
83
+ system.memory.writeByte((block.addr + i) & 0xffff, block.data[i]);
84
+ }
85
+ }
86
+ return startProgram(system, parsed.entry);
87
+ }
88
+
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The loaders' hand-off to a loaded program, shared by fastLoadCmd (cmd-format.js) and
3
+ * fastLoadSystem (cas-format.js).
4
+ */
5
+
6
+ /**
7
+ * Jump to `entry` with nothing of the interrupted code left half-done. The CPU leaves HALT. An EI
8
+ * still in flight (it enables interrupts only after the next instruction) takes effect now, so
9
+ * IFF1 and IFF2 are set as they would be one instruction later; a DD/FD prefix still pending is
10
+ * dropped. Any interrupt request latched before the load is cleared, so no request from before
11
+ * the load interrupts the program; the next request is the next one the hardware raises.
12
+ * Otherwise IFF1, IFF2, and the interrupt mode are left as they were.
13
+ * @param {any} system a TRS80System (a bare { cpu } works too)
14
+ * @param {number} entry
15
+ * @returns {number} the entry
16
+ */
17
+ export function startProgram(system, entry) {
18
+ const cpu = system.cpu;
19
+ cpu.halted = false;
20
+ if (cpu.pendingEI > 0) {
21
+ cpu.IFF1 = true;
22
+ cpu.IFF2 = true;
23
+ }
24
+ cpu.pendingEI = 0;
25
+ cpu.afterPrefix = false;
26
+ if (system.io) system.io.intLatch = 0;
27
+ cpu.registers.PC = entry;
28
+ return entry;
29
+ }