@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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Model descriptions: what differs between the TRS-80 models the Machine builds, as data, so the
3
+ * memory map, the I/O ports, the clock, and the ROM checks read one description instead of
4
+ * hard-coding the Model III (spec §5).
5
+ *
6
+ * Model I facts and where they come from (the ROM sources are github.com/kiwisincebirth/TRS-80-ROMS;
7
+ * spec §5 lists what is still unverified against the Model I Technical Reference Manual):
8
+ * - ROM: 12 KB at $0000-$2FFF. The repository's Model I Level II images `test-roms/M1L2_1.2.bin`
9
+ * and `M1L2_1.3.bin` are 12,288 bytes each, and its MODEL1.md describes `MDL1LEV2.Z80` as the
10
+ * source of the "Model I Level 2 12KB ROMS".
11
+ * - Keyboard matrix at $3800, video RAM at $3C00 (400H bytes), RAM from $4000: CONSTANTS.Z80,
12
+ * "Memory Mapped Hardware" and "RAM Communications Area".
13
+ * - $37E0-$37EF belong to the Expansion Interface (drive select $37E1, cassette select $37E4,
14
+ * printer $37E8, the disk controller $37EC-$37EF: CONSTANTS.Z80, "Memory Mapped Hardware,
15
+ * Model 1"). Without the Expansion Interface those addresses read $FF: MDL1LEV2.Z80 at $0069
16
+ * ("$FF if no expansion interface present") and $0696-$069C, where the ROM takes $FF (or $00) at
17
+ * $37EC as "no disk controller" and goes to Level II BASIC. The rest of $3000-$37FF decodes to
18
+ * nothing and is taken to read $FF as well (unverified).
19
+ * - RAM sizes 4, 16, 32, and 48 KB from $4000 (spec §5, unverified against the manual; Ruling 2
20
+ * of the phase 6 plan). Above the fitted RAM a read is taken to float to $FF and a write is
21
+ * lost (unverified); the ROM's MEMORY SIZE? probe at $00C4-$00D4 needs only that a byte there
22
+ * does not hold the complement written to it.
23
+ * - Clock 1,774,080 Hz: spec §5's 1.774 MHz, taken as the 10.6445 MHz crystal divided by 6 (as
24
+ * xtrs runs it; unverified against the manual). The ROM's PAUSE loop at $0060 is commented
25
+ * "BC times 14.65" µs at $06AF, and one pass of it is 26 T-states, which puts the clock at
26
+ * 1.775 MHz.
27
+ */
28
+
29
+ /**
30
+ * @typedef {{ id: 1 | 3, name: string, clockHz: number, timerHz: number, romTop: number,
31
+ * romSizes: readonly number[], romSizeText: string, ram: readonly number[], defaultRam: number,
32
+ * ramNote: string, roms: readonly string[], romTitle: string, stateMachine: string }} Model
33
+ */
34
+
35
+ /** @type {Model} */
36
+ export const MODEL_I = Object.freeze({
37
+ id: 1,
38
+ name: 'Model I',
39
+ clockHz: 1774080,
40
+ // The Expansion Interface's 40 Hz clock, raised through its latch at $37E0
41
+ // (devices/interrupt-latch.js, which cites it); none without the Expansion Interface.
42
+ timerHz: 40,
43
+ romTop: 0x3000,
44
+ romSizes: Object.freeze([0x3000]),
45
+ romSizeText: "The Model I's Level II ROM is 12KB (0x3000)",
46
+ ram: Object.freeze([4, 16, 32, 48]),
47
+ defaultRam: 48,
48
+ ramNote: '',
49
+ roms: Object.freeze(['model1-level2-1.2', 'model1-level2-1.3']),
50
+ romTitle: 'the Model I Level II ROM',
51
+ stateMachine: 'trs80-model1',
52
+ });
53
+
54
+ /** @type {Model} */
55
+ export const MODEL_III = Object.freeze({
56
+ id: 3,
57
+ name: 'Model III',
58
+ clockHz: 2027520,
59
+ timerHz: 30,
60
+ romTop: 0x3800,
61
+ // The real 14K image, or a 16K padded image of it (only $0000-$37FF is mapped).
62
+ romSizes: Object.freeze([0x3800, 0x4000]),
63
+ romSizeText: 'Expected 14KB (0x3800) or a 16KB (0x4000) padded image',
64
+ ram: Object.freeze([48]),
65
+ defaultRam: 48,
66
+ // Ruling 2 of the phase 6 plan: 16K and 32K are cheap but would run untested programs.
67
+ ramNote: '; 16K and 32K come in a later phase',
68
+ roms: Object.freeze(['model3-rev-c-2ef8', 'model3-rev-c-frehd']),
69
+ romTitle: 'the Model III ROM',
70
+ stateMachine: 'trs80-model3',
71
+ });
72
+
73
+ /** The models built, by number. */
74
+ export const MODELS = Object.freeze({ 1: MODEL_I, 3: MODEL_III });
75
+
76
+ /**
77
+ * The description of model `n`, or a sentence-long error for a model not built.
78
+ * @param {unknown} n
79
+ * @returns {Model}
80
+ */
81
+ export function modelOf(n) {
82
+ if (n === 1) return MODEL_I;
83
+ if (n === 3) return MODEL_III;
84
+ if (n === 4) throw new Error('There is no Model 4 yet: it needs a Model 4 ROM and boot disk to prove it boots, and comes in its own plan when one is supplied; use model 1 or 3.');
85
+ throw new Error(`model must be 1 (the Model I) or 3 (the Model III), not ${String(n)}.`);
86
+ }
87
+
88
+ /**
89
+ * Why `ram` (in K) is not a size model `desc` is built with, as a sentence, or null when it is.
90
+ * @param {Model} desc
91
+ * @param {unknown} ram
92
+ * @returns {string | null}
93
+ */
94
+ export function ramProblem(desc, ram) {
95
+ if (desc.ram.includes(/** @type {number} */ (ram))) return null;
96
+ const sizes = desc.ram.length === 1 ? String(desc.ram[0]) : `${desc.ram.slice(0, -1).join(', ')}, or ${desc.ram.at(-1)}`;
97
+ const got = typeof ram === 'string' ? JSON.stringify(ram) : String(ram);
98
+ return `The ${desc.name} takes ram: ${sizes} (K), not ${got}${desc.ramNote}.`;
99
+ }
100
+
101
+ /**
102
+ * The CPU clock of model `n`, in Hz.
103
+ * @param {1 | 3} n
104
+ */
105
+ export function clockHz(n) {
106
+ return modelOf(n).clockHz;
107
+ }
108
+
109
+ /**
110
+ * Why a recognised ROM cannot run on model `n`, as a sentence, or null when it can (or is not
111
+ * recognised: an unknown image is checked by its size alone).
112
+ * @param {string | null} recognized the ROM_HASHES name
113
+ * @param {1 | 3} n
114
+ * @returns {string | null}
115
+ */
116
+ export function romModelProblem(recognized, n) {
117
+ if (recognized === null) return null;
118
+ const wanted = modelOf(n);
119
+ if (wanted.roms.includes(recognized)) return null;
120
+ const owner = [MODEL_I, MODEL_III].find((d) => d.roms.includes(recognized));
121
+ if (owner === undefined) return null;
122
+ return `This ROM is ${owner.romTitle} (${recognized}); it runs on the ${owner.name}, not the ${wanted.name}: construct the Machine with model: ${owner.id}.`;
123
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Known ROM images by SHA-256 (lowercase hex), so the Machine can report which ROM it was given
3
+ * and refuse one given for the other model (spec §5: the core validates by length and a
4
+ * known-hash table). Hashes are facts about the images; the images themselves are never committed
5
+ * or bundled. Each name starts with its model (`model1-`, `model3-`); src/machine/models.js lists
6
+ * which names each model runs.
7
+ *
8
+ * Source for all: github.com/kiwisincebirth/TRS-80-ROMS, folder test-roms/.
9
+ * - `M1L2_1.2.bin` and `M1L2_1.3.bin` (12,288 bytes each): the Model I Level II ROM, versions 1.2
10
+ * and 1.3 (the repository's MODEL1.md: "Both 1.3 and 1.2 versions"), hashed 2026-10-18; 1.3 is
11
+ * the image the repository's fugloy.mcp.json names as roms.model1.
12
+ * - `Model3-RevC-2EF8.bin` (14,336 bytes): the stock Model III Rev C ROM and the image named as
13
+ * roms.model3; `Model3-RevC-FreHD.bin` is the same ROM patched to boot a FreHD hard-disk
14
+ * emulator. Hashed 2026-10-17.
15
+ */
16
+ export const ROM_HASHES = Object.freeze({
17
+ '3cd3824d2ac2743750d2179951ed812794302b885ccb4f98aa2579b352c4b00e': 'model1-level2-1.2',
18
+ '5515d95d7c19058400a2e91c6930bb557035db5ce093bd96fd017f795c346776': 'model1-level2-1.3',
19
+ beeb3741175a8b8706cc384d5b2534ebf32e383662864054e5a0ce4d861a43bb: 'model3-rev-c-2ef8',
20
+ ad6de30c6aece2d6a2243ee90a2959295d5c46b82650b27f46f1ecf8b34b2361: 'model3-rev-c-frehd',
21
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * SHA-256 (FIPS 180-4) in plain JavaScript, because the core may use neither Node's crypto nor
3
+ * the browser's asynchronous Web Crypto. A copy of @fugloy/disasm's `src/hash.ts` (emu must not
4
+ * depend on disasm); identifies ROM images against ROM_HASHES.
5
+ */
6
+ const K = new Uint32Array([
7
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
8
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
9
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
10
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
11
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
12
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
13
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
14
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
15
+ ]);
16
+
17
+ const H0 = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
18
+
19
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
20
+
21
+ /**
22
+ * @param {Uint8Array} bytes
23
+ * @returns {Uint8Array} the 32-byte digest
24
+ */
25
+ export function sha256(bytes) {
26
+ const len = bytes.length;
27
+ const padded = new Uint8Array(((len + 9 + 63) >> 6) << 6);
28
+ padded.set(bytes);
29
+ padded[len] = 0x80;
30
+ const view = new DataView(padded.buffer);
31
+ const bits = len * 8;
32
+ view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000));
33
+ view.setUint32(padded.length - 4, bits >>> 0);
34
+
35
+ const h = Uint32Array.from(H0);
36
+ const w = new Uint32Array(64);
37
+ for (let off = 0; off < padded.length; off += 64) {
38
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
39
+ for (let i = 16; i < 64; i++) {
40
+ const w15 = w[i - 15];
41
+ const w2 = w[i - 2];
42
+ const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3);
43
+ const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10);
44
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
45
+ }
46
+ let a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
47
+ for (let i = 0; i < 64; i++) {
48
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
49
+ const ch = (e & f) ^ (~e & g);
50
+ const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0;
51
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
52
+ const maj = (a & b) ^ (a & c) ^ (b & c);
53
+ const t2 = (S0 + maj) >>> 0;
54
+ hh = g; g = f; f = e;
55
+ e = (d + t1) >>> 0;
56
+ d = c; c = b; b = a;
57
+ a = (t1 + t2) >>> 0;
58
+ }
59
+ h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0;
60
+ h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0;
61
+ }
62
+ const out = new Uint8Array(32);
63
+ const outView = new DataView(out.buffer);
64
+ for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i]);
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * @param {Uint8Array} bytes
70
+ * @returns {string} the digest as 64 lowercase hex digits
71
+ */
72
+ export function sha256Hex(bytes) {
73
+ return Array.from(sha256(bytes), (b) => b.toString(16).padStart(2, '0')).join('');
74
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * TRS-80 System, Model III or Model I
3
+ *
4
+ * Owns and wires the machine: Z80 CPU, memory map, I/O ports, and the
5
+ * keyboard matrix, laid out by the model description (models.js). Runs the
6
+ * CPU in T-state-budgeted slices at the model's clock (the Model III's real
7
+ * 2.02752 MHz) and raises the Model III's 30 Hz RTC heartbeat interrupt the
8
+ * ROM's clock/cursor services depend on, or the Model I Expansion
9
+ * Interface's 40 Hz clock, through its latch at $37E0
10
+ * (devices/interrupt-latch.js); a Model I without the Expansion Interface
11
+ * has no clock and takes no interrupts.
12
+ *
13
+ * Rendering is the caller's concern: read `memory.videoRam` (or
14
+ * `screenText()`) and consult `memory.videoDirty`. Keyboard input goes
15
+ * through `system.keyboard` (press and release keys directly).
16
+ */
17
+
18
+ import { Z80CPU } from "../cpu/z80cpu.js";
19
+ import { MemorySystem } from "./memory.js";
20
+ import { IOSystem, MODE_32COL_BIT, M1_MODESEL_BIT } from "./io.js";
21
+ import { TIMER_BIT } from "../devices/interrupt-latch.js";
22
+ import { KeyboardMatrix } from "../devices/keyboard.js";
23
+ import { MODEL_III, modelOf } from "./models.js";
24
+
25
+ /** The Model III's clock and RTC; a system's own are `clockHz` and `rtcInterval`. */
26
+ export const CPU_CLOCK_HZ = MODEL_III.clockHz;
27
+ export const RTC_HZ = MODEL_III.timerHz;
28
+ export const RTC_INTERVAL_TSTATES = CPU_CLOCK_HZ / RTC_HZ; // 67,584
29
+
30
+ export class TRS80System {
31
+ /**
32
+ * @param {{ romData: Uint8Array, log?: (level: string, text: string) => void,
33
+ * model?: 1 | 3 | import('./models.js').Model, ram?: number, expansion?: boolean, lowercase?: boolean }} opts
34
+ * the model (a number or a description; the Model III by default), its RAM in K (the model's
35
+ * default), and the Model I's Expansion Interface (fitted by default) and lowercase modification
36
+ * (not fitted by default); the Machine checks them
37
+ */
38
+ constructor({ romData, log, model = MODEL_III, ram, expansion = true, lowercase = false }) {
39
+ const desc = typeof model === "number" ? modelOf(model) : model;
40
+ this.model = desc;
41
+ this.clockHz = desc.clockHz;
42
+ this.rtcInterval = desc.clockHz / desc.timerHz;
43
+ this.expansion = expansion;
44
+ this.lowercase = lowercase;
45
+ this.memory = new MemorySystem({ model: desc, ram: ram ?? desc.defaultRam, lowercase });
46
+ this.io = new IOSystem({ model: desc, expansion });
47
+ // The Model I's Expansion Interface addresses go to the I/O decode
48
+ if (this.io.latch !== null) this.memory.expansion = this.io;
49
+ // The 32-column bit: port $EC bit 2 on the Model III, port $FF bit 3 (MODESEL) on the Model I
50
+ this._mode32Bit = desc.id === 1 ? M1_MODESEL_BIT : MODE_32COL_BIT;
51
+ this.keyboard = new KeyboardMatrix();
52
+ this.cpu = new Z80CPU();
53
+ if (log !== undefined) {
54
+ this.cpu.log = log;
55
+ this.io.cassette.log = log;
56
+ }
57
+
58
+ this.memory.keyboard = this.keyboard;
59
+
60
+ this.cpu.readMemory = (addr) => this.memory.readByte(addr);
61
+ this.cpu.writeMemory = (addr, value) => this.memory.writeByte(addr, value);
62
+ this.cpu.readPort = (port) => this.io.readPort(port);
63
+ this.cpu.writePort = (port, value) => this.io.writePort(port, value);
64
+
65
+ // A mode-register change (e.g. the ROM handling CHR$(23)) redraws
66
+ // the same video RAM in a different layout — force a repaint.
67
+ this.io.onModeWrite = () => {
68
+ this.memory.videoDirty = true;
69
+ };
70
+
71
+ // Cassette-out sound transitions are stamped in CPU T-states
72
+ this.io.getCycles = () => this.cpu.cycles;
73
+
74
+ // The Model III WAIT (port F4 bit 6): the controller runs ahead to its
75
+ // next DRQ or INTRQ; the CPU's clock catches up, and stepInstruction
76
+ // does not tick the controller for those T-states again. A port write
77
+ // from outside an instruction (a debugger poke) does not wait.
78
+ this._executing = false;
79
+ this._fdcAhead = 0;
80
+ this.io.onWait = (runToDrq) => {
81
+ if (!this._executing) return;
82
+ const held = runToDrq();
83
+ this.cpu.cycles += held;
84
+ this._fdcAhead += held;
85
+ };
86
+
87
+ this.memory.loadROM(romData);
88
+
89
+ this.nextRtcAt = this.rtcInterval;
90
+ this.reset();
91
+ }
92
+
93
+ /**
94
+ * Hardware reset (the orange button): CPU restarts at 0x0000, interrupt
95
+ * latches clear, screen blanks. RAM contents survive, as on the real
96
+ * machine.
97
+ */
98
+ reset() {
99
+ this.cpu.reset();
100
+ this.io.reset();
101
+ this.keyboard.reset();
102
+ this.memory.clearVideoRAM();
103
+ this.nextRtcAt = this.cpu.cycles + this.rtcInterval;
104
+ this._nmiWasPending = false;
105
+ }
106
+
107
+ /**
108
+ * Execute instructions until `budget` T-states have elapsed, raising
109
+ * the RTC heartbeat on schedule and delivering pending interrupts
110
+ * between instructions. Returns the T-states actually consumed (the
111
+ * final instruction may overshoot slightly).
112
+ */
113
+ runTStates(budget) {
114
+ const start = this.cpu.cycles;
115
+ const target = start + budget;
116
+
117
+ while (this.cpu.cycles < target) {
118
+ this.stepInstruction();
119
+ }
120
+
121
+ return this.cpu.cycles - start;
122
+ }
123
+
124
+ /**
125
+ * One instruction boundary, the only place the machine advances: raise
126
+ * the RTC heartbeat when its T-state is due, deliver a pending
127
+ * interrupt or NMI edge, then call `beforeExecute` (if given) and
128
+ * execute one instruction, ticking the disk controller by its cost.
129
+ * `runTStates` and the Machine facade both step through here, so the
130
+ * RTC keeps one schedule however the machine is driven. Returns the
131
+ * T-states consumed, an interrupt response included.
132
+ * @param {() => boolean | 'stop' | void} [beforeExecute] called after interrupt delivery, with PC at the instruction about to run; returning true means it stood in for that instruction, which then does not execute; returning 'stop' means no instruction executes at all (a debugger break)
133
+ * @returns {number}
134
+ */
135
+ stepInstruction(beforeExecute) {
136
+ const start = this.cpu.cycles;
137
+ if (this.cpu.cycles >= this.nextRtcAt) {
138
+ if (this.model.id === 3) this.io.raiseRTC();
139
+ else if (this.io.latch !== null) this.io.latch.raise(TIMER_BIT);
140
+ this.nextRtcAt += this.rtcInterval;
141
+ }
142
+ if (this.io.pendingInterrupt()) {
143
+ this.cpu.interrupt();
144
+ }
145
+ // FDC INTRQ -> NMI, edge-triggered so one assertion fires one NMI. The
146
+ // CPU holds an NMI off after a lone DD/FD prefix, as it does a maskable
147
+ // interrupt (Young §3.6); the edge then stays pending for the next
148
+ // boundary.
149
+ const nmiNow = this.io.nmiPending();
150
+ if (nmiNow && !this._nmiWasPending) {
151
+ if (this.cpu.nmi()) this._nmiWasPending = true;
152
+ } else {
153
+ this._nmiWasPending = nmiNow;
154
+ }
155
+
156
+ // The disk controller ticks by every T-state the boundary takes, an interrupt or NMI
157
+ // response included, so its clock (the disk's angle) keeps step with the CPU's: the response
158
+ // first, so an OUT that sets the WAIT finds the controller at the start of that OUT; then the
159
+ // instruction, less the T-states the WAIT already ran it ahead for (`_fdcAhead`).
160
+ const before = this.cpu.cycles;
161
+ if (before !== start) this.io.fdc.tick(before - start);
162
+
163
+ // beforeExecute returning true has executed the boundary's instruction itself (the
164
+ // Machine's ROM keyboard-scan hook stands in for a RET), so the CPU does not run one.
165
+ // Returning 'stop' runs nothing; the return value is then the response's T-states.
166
+ // try/finally: a callback or an instruction that throws must not leave `_executing` set, or
167
+ // a later debugger port poke would wait as if an instruction were running.
168
+ this._executing = true;
169
+ try {
170
+ if (beforeExecute !== undefined) {
171
+ const r = beforeExecute();
172
+ if (r === 'stop') return before - start;
173
+ if (r !== true) this.cpu.executeInstruction();
174
+ } else {
175
+ this.cpu.executeInstruction();
176
+ }
177
+ } catch (e) {
178
+ this._fdcAhead = 0;
179
+ throw e;
180
+ } finally {
181
+ this._executing = false;
182
+ }
183
+ this.io.fdc.tick(this.cpu.cycles - before - this._fdcAhead);
184
+ this._fdcAhead = 0;
185
+ return this.cpu.cycles - start;
186
+ }
187
+
188
+ /**
189
+ * True when the machine is in 32-character mode (on the Model III the
190
+ * mode register's bit 2, on the Model I port $FF's MODESEL, bit 3; set by
191
+ * the ROM for CHR$(23), cleared by CLS). The ROM lays 32-column text at
192
+ * even video addresses; hardware shows them double-wide.
193
+ */
194
+ get columns32() {
195
+ return (this.io.modeRegister & this._mode32Bit) !== 0;
196
+ }
197
+
198
+ /** Mount a .dsk image (DiskImage) in drive 0-3. */
199
+ mountDisk(driveNumber, image) {
200
+ this.io.fdc.attachDrive(driveNumber, image);
201
+ }
202
+
203
+ /**
204
+ * Eject every drive and reboot into cassette BASIC, answering the
205
+ * ROM's Cass? and Memory Size? prompts. Cassette-world loaders (the
206
+ * library, .cas tapes, paste-BASIC) need the machine at READY — under
207
+ * a booted DOS their typed RUN goes to the DOS prompt instead. Runs
208
+ * ~3.5 emulated seconds inline, a blink of wall clock.
209
+ * @returns {boolean} true when the READY prompt was reached
210
+ */
211
+ bootToCassetteBasic() {
212
+ for (let drive = 0; drive < 4; drive++) {
213
+ this.ejectDisk(drive);
214
+ }
215
+ this.reset();
216
+ const pressEnter = () => {
217
+ this.keyboard.keyDown("Enter", "bootdance");
218
+ this.runTStates(200000);
219
+ this.keyboard.keyUp("bootdance");
220
+ this.runTStates(100000);
221
+ };
222
+ this.runSeconds(1);
223
+ pressEnter();
224
+ this.runSeconds(0.5);
225
+ pressEnter();
226
+ this.runSeconds(2);
227
+ return this.screenText().join("\n").includes("READY");
228
+ }
229
+
230
+ ejectDisk(driveNumber) {
231
+ this.io.fdc.ejectDrive(driveNumber);
232
+ }
233
+
234
+ /** Convenience for tests: run N emulated seconds. */
235
+ runSeconds(seconds) {
236
+ return this.runTStates(Math.round(seconds * this.clockHz));
237
+ }
238
+
239
+ /**
240
+ * Type text through the keyboard matrix at emulated speed ("turbo
241
+ * paste"): each key is held long enough for the ROM's scan/debounce,
242
+ * but the wall-clock cost is only the emulation time (~3 ms/char).
243
+ * '\n' presses ENTER. Returns the number of characters that had no
244
+ * matrix mapping and were skipped.
245
+ *
246
+ * After ENTER the ROM tokenizes and stores the line (cost grows with
247
+ * program size) before it scans the keyboard again — give it room or
248
+ * the first character of the next line gets eaten. The default suits
249
+ * the small built-in classics; pass a larger `enterTStates` for
250
+ * multi-KB listings (a 5.7 KB program needs ~1M).
251
+ */
252
+ typeText(text, { enterTStates = 400000 } = {}) {
253
+ let skipped = 0;
254
+ let seq = 0;
255
+ for (const ch of text) {
256
+ const key = ch === "\n" ? "Enter" : ch;
257
+ const code = `typeText:${seq++}`;
258
+ if (!this.keyboard.keyDown(key, code)) {
259
+ skipped++;
260
+ continue;
261
+ }
262
+ this.runTStates(70000); // held across several scans + debounce
263
+ this.keyboard.keyUp(code);
264
+ this.runTStates(ch === "\n" ? enterTStates : 50000);
265
+ }
266
+ return skipped;
267
+ }
268
+
269
+ /**
270
+ * The 64x16 screen as strings, for tests and status displays.
271
+ * Control bytes 0x00-0x1F display as their 0x40-0x5F fold (Model III
272
+ * character generator); block-graphics bytes render as '.'.
273
+ */
274
+ screenText() {
275
+ const rows = [];
276
+ for (let row = 0; row < 16; row++) {
277
+ let line = "";
278
+ for (let col = 0; col < 64; col++) {
279
+ let byte = this.memory.videoRam[row * 64 + col];
280
+ if (byte < 0x20) {
281
+ byte += 0x40;
282
+ }
283
+ line += byte >= 0x20 && byte < 0x80 ? String.fromCharCode(byte) : ".";
284
+ }
285
+ rows.push(line);
286
+ }
287
+ return rows;
288
+ }
289
+ }