@fugloy/emu 0.0.0-stage → 0.2.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,290 @@
1
+ /**
2
+ * TRS-80 Model III I/O System
3
+ *
4
+ * Non-disk machine port map:
5
+ * 0xE0-0xE3 read: interrupt latch, active-low (pending bit reads 0)
6
+ * write: interrupt mask (bit set = source enabled)
7
+ * 0xEC-0xEF read: acknowledge/clear the RTC interrupt
8
+ * write: mode register (bit 1 = cassette motor,
9
+ * bit 2 = 32-column mode, bit 3 = alt charset)
10
+ * 0xF0-0xF3 floppy controller — absent; reads float to 0xFF
11
+ * 0xF4 drive select — absent
12
+ * 0xF8-0xFB line printer (read: status, write: data byte)
13
+ * 0xFF cassette port (write bits 0-1 = output level,
14
+ * read bit 7 = tape input)
15
+ *
16
+ * The RTC heartbeat interrupt fires at 30 Hz; its latch bit is RTC_IRQ_BIT.
17
+ * The keyboard is memory-mapped at 0x3800-0x3BFF (see MemorySystem), not
18
+ * an I/O port.
19
+ */
20
+
21
+ import { CassetteSystem } from "../devices/cassette.js";
22
+ import { FDC1793 } from "../devices/fdc-wd1793.js";
23
+ import { Printer } from "../devices/printer.js";
24
+
25
+ export const RTC_IRQ_BIT = 0x04;
26
+ // Cassette-edge interrupt sources (from the ROM's ISR at 0x35C2: E0
27
+ // bit 0 dispatches to a handler expecting one input level, bit 1 the
28
+ // other; each reads the level back from port 0xFF bit 0). The 1500-baud
29
+ // reader masks in rises only (0xE0 write = 0x01) and counts full
30
+ // cycles between interrupts.
31
+ export const CASSETTE_RISE_IRQ_BIT = 0x01;
32
+ export const CASSETTE_FALL_IRQ_BIT = 0x02;
33
+
34
+ // NMI mask/status bits (port 0xE4)
35
+ export const NMI_INTRQ_BIT = 0x80;
36
+
37
+ // Mode register (port 0xEC write) bits
38
+ export const MODE_32COL_BIT = 0x04; // 32-character display mode
39
+
40
+ export class IOSystem {
41
+ constructor() {
42
+ this.cassette = new CassetteSystem();
43
+ this.fdc = new FDC1793();
44
+ this.printer = new Printer();
45
+ this.keyboardBuffer = []; // legacy internal buffer (not a Model III port)
46
+ this.portHandlers = new Map();
47
+
48
+ // Interrupt latch (pending sources) and mask (enabled sources)
49
+ this.intLatch = 0;
50
+ this.intMask = 0;
51
+
52
+ // NMI mask (port 0xE4 write): bit 7 = FDC INTRQ triggers NMI
53
+ this.nmiMask = 0;
54
+
55
+ // Mode register (port 0xEC write)
56
+ this.modeRegister = 0;
57
+
58
+ // Fired when a mode-register write changes the value (the display
59
+ // reads bit 2, so the renderer must repaint even though video RAM
60
+ // itself didn't change). Wired by TRS80System.
61
+ this.onModeWrite = null;
62
+
63
+ // Cassette output level (port 0xFF write, bits 0-1)
64
+ this.cassetteOut = 0;
65
+
66
+ // Cassette-out transitions for the sound driver: {t, level} stamped
67
+ // in CPU T-states. getCycles is wired by TRS80System; the driver
68
+ // drains the log every frame. Capped so an undrained log (sound off,
69
+ // headless run) cannot grow without bound.
70
+ this.getCycles = null;
71
+ this.soundLog = [];
72
+ this.SOUND_LOG_LIMIT = 100000;
73
+ // Raw port 0xFF write sink (cycles, value): the Machine's sound ring.
74
+ this.onSoundWrite = null;
75
+ // Called when the timeline restarts (state restore): the Machine clears its sound ring.
76
+ this.onSoundReset = null;
77
+
78
+ this.initializeModelIIIPorts();
79
+ }
80
+
81
+ initializeModelIIIPorts() {
82
+ // 0xE0-0xE3: interrupt latch (read, active-low) / mask (write)
83
+ for (let port = 0xe0; port <= 0xe3; port++) {
84
+ this.portHandlers.set(port, {
85
+ read: () => ~this.intLatch & 0xff,
86
+ write: (value) => {
87
+ this.intMask = value & 0xff;
88
+ },
89
+ });
90
+ }
91
+
92
+ // 0xEC-0xEF: RTC acknowledge (read) / mode register (write)
93
+ for (let port = 0xec; port <= 0xef; port++) {
94
+ this.portHandlers.set(port, {
95
+ read: () => {
96
+ this.intLatch &= ~RTC_IRQ_BIT;
97
+ return 0xff;
98
+ },
99
+ write: (value) => {
100
+ const changed = this.modeRegister !== (value & 0xff);
101
+ this.modeRegister = value & 0xff;
102
+ // Mode register bit 1 drives the cassette motor relay;
103
+ // CassetteSystem.control expects the motor state in bit 0.
104
+ this.cassette.control(
105
+ (value >> 1) & 0x01,
106
+ this.getCycles ? this.getCycles() : 0
107
+ );
108
+ if (changed && this.onModeWrite) {
109
+ this.onModeWrite(this.modeRegister);
110
+ }
111
+ },
112
+ });
113
+ }
114
+
115
+ // 0xE4-0xE7: disk NMI mask (write) / status (read, active-low).
116
+ // With no controller "present" (no disk mounted) the port floats.
117
+ for (let port = 0xe4; port <= 0xe7; port++) {
118
+ this.portHandlers.set(port, {
119
+ read: () => {
120
+ if (!this.fdc.anyDiskMounted()) return 0xff;
121
+ let value = 0xff;
122
+ if (this.fdc.intrq) value &= ~NMI_INTRQ_BIT;
123
+ return value;
124
+ },
125
+ write: (value) => {
126
+ this.nmiMask = value & 0xff;
127
+ },
128
+ });
129
+ }
130
+
131
+ // 0xF0-0xF3: WD1793 FDC. The controller presents as absent (floating
132
+ // bus) until a disk is mounted — that float is what sends the boot
133
+ // ROM to cassette BASIC on a driveless machine.
134
+ const fdcPresent = () => this.fdc.anyDiskMounted();
135
+ this.portHandlers.set(0xf0, {
136
+ read: () => (fdcPresent() ? this.fdc.readStatus() : 0xff),
137
+ write: (v) => {
138
+ if (fdcPresent()) this.fdc.writeCommand(v);
139
+ },
140
+ });
141
+ this.portHandlers.set(0xf1, {
142
+ read: () => (fdcPresent() ? this.fdc.readTrackReg() : 0xff),
143
+ write: (v) => {
144
+ if (fdcPresent()) this.fdc.writeTrackReg(v);
145
+ },
146
+ });
147
+ this.portHandlers.set(0xf2, {
148
+ read: () => (fdcPresent() ? this.fdc.readSectorReg() : 0xff),
149
+ write: (v) => {
150
+ if (fdcPresent()) this.fdc.writeSectorReg(v);
151
+ },
152
+ });
153
+ this.portHandlers.set(0xf3, {
154
+ read: () => (fdcPresent() ? this.fdc.readData() : 0xff),
155
+ write: (v) => {
156
+ if (fdcPresent()) this.fdc.writeData(v);
157
+ },
158
+ });
159
+
160
+ // 0xF4: drive select / side / density latch
161
+ this.portHandlers.set(0xf4, {
162
+ read: () => 0xff,
163
+ write: (v) => {
164
+ if (fdcPresent()) this.fdc.selectDrive(v);
165
+ },
166
+ });
167
+
168
+ // 0xF8-0xFB: line printer. Status reads ready; data writes land on
169
+ // the printout. Note io.reset() leaves the printout alone — paper
170
+ // already printed survives a reboot on the real machine too.
171
+ for (let port = 0xf8; port <= 0xfb; port++) {
172
+ this.portHandlers.set(port, {
173
+ read: () => this.printer.status(),
174
+ write: (v) => this.printer.write(v),
175
+ });
176
+ }
177
+
178
+ // 0xFF: cassette data port. Reads return the input latch in bit 7
179
+ // (set by tape transitions while the motor runs); ANY write clears
180
+ // the latch — that's the Model III's latch circuit, and it's how
181
+ // the ROM's read routines re-arm between pulses. Output level
182
+ // changes are the machine's only sound source, so they're logged
183
+ // for the audio driver, and they double as the recording the deck
184
+ // can decode back into a .cas.
185
+ this.portHandlers.set(0xff, {
186
+ read: () => {
187
+ const cycles = this.getCycles ? this.getCycles() : 0;
188
+ // Two input bits, straight from the ROM's own readers:
189
+ // bit 0 — the tape signal's live LEVEL (the 1500-baud ISR
190
+ // does IN A,(0xFF) / AND 0x01 / CP E per edge);
191
+ // bit 7 — a rising-edge PULSE LATCH (the 500-baud reader
192
+ // polls it with RLA and resets it by OUT (0xFF),A).
193
+ // Reading also acks the edge interrupt sources: the ISR reads
194
+ // 0xE0 then 0xFF and nothing else.
195
+ this.intLatch &= ~(CASSETTE_RISE_IRQ_BIT | CASSETTE_FALL_IRQ_BIT);
196
+ const level = this.cassette.tapeLevel(cycles);
197
+ return (this.cassette.pulseLatch ? 0x80 : 0x00) | (level ? 0x01 : 0x00);
198
+ },
199
+ write: (value) => {
200
+ const cycles = this.getCycles ? this.getCycles() : 0;
201
+ const level = value & 0x03;
202
+ if (this.onSoundWrite) this.onSoundWrite(cycles, value);
203
+ if (level !== this.cassetteOut) {
204
+ this.cassetteOut = level;
205
+ if (this.getCycles && this.soundLog.length < this.SOUND_LOG_LIMIT) {
206
+ this.soundLog.push({ t: cycles, level });
207
+ }
208
+ this.cassette.recordTransition(cycles, level);
209
+ }
210
+ this.cassette.clearPulseLatch(cycles);
211
+ },
212
+ });
213
+ }
214
+
215
+ readPort(port) {
216
+ port &= 0xff;
217
+ const handler = this.portHandlers.get(port);
218
+ return handler?.read ? handler.read() : 0xff;
219
+ }
220
+
221
+ writePort(port, value) {
222
+ port &= 0xff;
223
+ value &= 0xff;
224
+ const handler = this.portHandlers.get(port);
225
+ if (handler?.write) {
226
+ handler.write(value);
227
+ }
228
+ }
229
+
230
+ /** Raise the 30 Hz real-time clock interrupt line. */
231
+ raiseRTC() {
232
+ this.intLatch |= RTC_IRQ_BIT;
233
+ }
234
+
235
+ /** True when an enabled interrupt source is pending. */
236
+ pendingInterrupt() {
237
+ // The cassette edge is a live source: advance the pulse deck to
238
+ // "now" so an edge that just played raises the latch bit without
239
+ // waiting for a port access. Guarded, so machines without a tape
240
+ // playing pay one property check.
241
+ if (this.cassette.pulseTape && this.cassette.motorOn && this.getCycles) {
242
+ const edges = this.cassette.consumeEdges(this.getCycles());
243
+ if (edges.rise) this.intLatch |= CASSETTE_RISE_IRQ_BIT;
244
+ if (edges.fall) this.intLatch |= CASSETTE_FALL_IRQ_BIT;
245
+ }
246
+ return (this.intLatch & this.intMask) !== 0;
247
+ }
248
+
249
+ /** True when the FDC's INTRQ should raise an NMI. */
250
+ nmiPending() {
251
+ return this.fdc.intrq && (this.nmiMask & NMI_INTRQ_BIT) !== 0;
252
+ }
253
+
254
+ /** Hand the accumulated cassette-out transitions to the sound driver. */
255
+ drainSound() {
256
+ if (this.soundLog.length === 0) return this.soundLog;
257
+ const log = this.soundLog;
258
+ this.soundLog = [];
259
+ return log;
260
+ }
261
+
262
+ /** Clear interrupt state (system reset). Mounted disks stay mounted. */
263
+ reset() {
264
+ this.intLatch = 0;
265
+ this.intMask = 0;
266
+ this.nmiMask = 0;
267
+ this.modeRegister = 0;
268
+ this.cassetteOut = 0;
269
+ this.soundLog = [];
270
+ this.fdc.intrq = false;
271
+ this.fdc.pending = null;
272
+ this.fdc.buffer = null;
273
+ this.fdc.status = 0;
274
+ this.fdc.commandType = 1;
275
+ }
276
+
277
+ readKeyboard() {
278
+ return this.keyboardBuffer.length > 0 ? this.keyboardBuffer.shift() : 0x00;
279
+ }
280
+
281
+ addKey(keyCode) {
282
+ if (this.keyboardBuffer.length < 256) {
283
+ this.keyboardBuffer.push(keyCode & 0xff);
284
+ }
285
+ }
286
+
287
+ clearKeyboardBuffer() {
288
+ this.keyboardBuffer = [];
289
+ }
290
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * The spec §3 `Machine`: the public face of the emulator, over a TRS80System. Phase 2 builds
3
+ * the Model III with 48K; execution, state access, and ROM identification live here, and the
4
+ * device, loader, and debug surfaces arrive in later tasks.
5
+ */
6
+ import { TRS80System } from './trs80-system.js';
7
+ import { ROM_HASHES } from './rom-hashes.js';
8
+ import { sha256Hex } from './sha256.js';
9
+ import { Video } from '../devices/video.js';
10
+ import { Sound } from '../devices/sound.js';
11
+ import { loadBas } from '../loaders/bas.js';
12
+ import { cassetteDeck, loadCas, loadCmd } from '../loaders/load.js';
13
+ import { Debugger } from '../debug/debugger.js';
14
+ import { KeyQueue, romKeyReturn } from '../devices/key-queue.js';
15
+
16
+ /**
17
+ * @typedef {{ a: number, f: number, b: number, c: number, d: number, e: number, h: number, l: number,
18
+ * a2: number, f2: number, b2: number, c2: number, d2: number, e2: number, h2: number, l2: number,
19
+ * ix: number, iy: number, sp: number, pc: number, i: number, r: number,
20
+ * iff1: boolean, iff2: boolean, im: number }} Regs
21
+ * @typedef {{ s: boolean, z: boolean, h: boolean, pv: boolean, n: boolean, c: boolean }} Flags
22
+ * @typedef {'halt' | ((m: Machine) => boolean)} Until
23
+ */
24
+
25
+ export class Machine {
26
+ /** @type {TRS80System} The wrapped system: an escape hatch to the devices, outside the stable API. */
27
+ system;
28
+ /** @type {{ bytes: number, sha256: string, recognized: string | null }} */
29
+ rom;
30
+ /** @type {{ readonly regs: Regs, readonly flags: Flags }} */
31
+ cpu;
32
+ /** @type {{ read(addr: number, len: number): Uint8Array, write(addr: number, bytes: ArrayLike<number>): void, readonly ram: Uint8Array }} */
33
+ mem;
34
+ /** @type {{ in(port: number): number, out(port: number, value: number): void }} */
35
+ io;
36
+ /** @type {Video} The text readout of video RAM. */
37
+ video;
38
+ /** @type {Sound} Every port $FF write, raw and stamped in T-states. */
39
+ sound;
40
+ /** @type {KeyQueue} Key presses now, cycle-timed typing through the matrix, and the ROM keyboard-scan hook's feed. */
41
+ keyboard;
42
+ /**
43
+ * @type {{ bas(bytes: Uint8Array): ReturnType<typeof loadBas>, cmd(bytes: Uint8Array): ReturnType<typeof loadCmd>,
44
+ * cas(bytes: Uint8Array, opts?: { baud?: 500 | 1500, via?: 'rom' | 'fast' }): ReturnType<typeof loadCas> }}
45
+ * Program loaders; each returns a manifest of where the bytes land and why.
46
+ */
47
+ load;
48
+ /** @type {ReturnType<typeof cassetteDeck>} The cassette deck: insert a .CAS at a baud, rewind, read the motor relay. */
49
+ cassette;
50
+ /** @type {Debugger} Breakpoints, watchpoints, hooks, coverage, the instruction trace, snapshots, labels, and the label file (spec §7, §8). */
51
+ debug;
52
+
53
+ /** True inside `run`, false inside `step`, which ignores breakpoints. */
54
+ #checkBreaks = false;
55
+ /** True until a run's first boundary has passed; that boundary is never checked, so a run resumes past a break. */
56
+ #firstBoundary = false;
57
+ /** @type {number | null} The PC where the last run stopped for a breakpoint: the next run's first boundary skips the check there only. */
58
+ #resumePc = null;
59
+ /** True while `step` or `run` executes, so a callback cannot re-enter either. */
60
+ #busy = false;
61
+ /** Set by the hook when a breakpoint held the boundary, read by `run`. */
62
+ #stopped = false;
63
+
64
+ /**
65
+ * @param {{ model?: 3, ram?: 48, roms: { level2: Uint8Array }, log?: (level: string, text: string) => void }} opts
66
+ */
67
+ constructor({ model = 3, ram = 48, roms, log } = /** @type {any} */ ({})) {
68
+ if (model !== 3 || ram !== 48) {
69
+ throw new Error(`phase 2 builds the Model III with 48K; got model ${model} with ${ram}K (the Model I, the Model 4, and other RAM sizes come in later phases)`);
70
+ }
71
+ const level2 = roms?.level2;
72
+ if (!ArrayBuffer.isView(level2) || level2.BYTES_PER_ELEMENT !== 1) throw new Error('Machine needs roms.level2, the ROM image as a Uint8Array');
73
+
74
+ this.system = new TRS80System({ romData: level2, log });
75
+ const sha256 = sha256Hex(level2);
76
+ this.rom = { bytes: level2.length, sha256, recognized: ROM_HASHES[sha256] ?? null };
77
+
78
+ const sys = this.system;
79
+ const cpu = sys.cpu;
80
+ this.cpu = {
81
+ get regs() {
82
+ const r = cpu.registers;
83
+ return {
84
+ a: r.A, f: r.F, b: r.B, c: r.C, d: r.D, e: r.E, h: r.H, l: r.L,
85
+ a2: r.A_, f2: r.F_, b2: r.B_, c2: r.C_, d2: r.D_, e2: r.E_, h2: r.H_, l2: r.L_,
86
+ ix: cpu.IX, iy: cpu.IY, sp: r.SP, pc: r.PC, i: r.I, r: r.R,
87
+ iff1: cpu.IFF1, iff2: cpu.IFF2, im: cpu.interruptMode,
88
+ };
89
+ },
90
+ get flags() {
91
+ const f = cpu.registers.F;
92
+ return { s: (f & 0x80) !== 0, z: (f & 0x40) !== 0, h: (f & 0x10) !== 0, pv: (f & 0x04) !== 0, n: (f & 0x02) !== 0, c: (f & 0x01) !== 0 };
93
+ },
94
+ };
95
+ this.mem = {
96
+ read(addr, len) {
97
+ const out = new Uint8Array(len);
98
+ for (let k = 0; k < len; k++) out[k] = sys.memory.readByte((addr + k) & 0xffff);
99
+ return out;
100
+ },
101
+ write(addr, bytes) {
102
+ for (let k = 0; k < bytes.length; k++) sys.memory.writeByte((addr + k) & 0xffff, bytes[k]);
103
+ },
104
+ get ram() {
105
+ return sys.memory.ram;
106
+ },
107
+ };
108
+ this.video = new Video(sys);
109
+ this.sound = new Sound();
110
+ sys.io.onSoundWrite = (cycle, value) => this.sound.record(cycle, value);
111
+ sys.io.onSoundReset = () => this.sound.clear();
112
+ this.keyboard = new KeyQueue(sys.keyboard, () => sys.cpu.cycles);
113
+ this.debug = new Debugger(this);
114
+ // Each load records its manifest and the bytes' hash for the label file (the last one wins).
115
+ const record = (/** @type {any} */ manifest, /** @type {Uint8Array} */ bytes) => {
116
+ this.debug.recordLoad(manifest, bytes);
117
+ return manifest;
118
+ };
119
+ this.load = {
120
+ bas: (bytes) => record(loadBas(this, bytes), bytes),
121
+ cmd: (bytes) => record(loadCmd(this, bytes), bytes),
122
+ cas: (bytes, opts) => record(loadCas(this, bytes, opts), bytes),
123
+ };
124
+ this.cassette = cassetteDeck(this);
125
+ this.io = {
126
+ in: (port) => sys.io.readPort(port),
127
+ out: (port, value) => sys.io.writePort(port, value),
128
+ };
129
+ }
130
+
131
+ /** Total T-states since the last reset. */
132
+ get cycles() {
133
+ return this.system.cpu.cycles;
134
+ }
135
+
136
+ /** Hardware reset: CPU to $0000, cycle count to 0, RAM kept (TRS80System.reset). */
137
+ reset() {
138
+ this.system.reset();
139
+ this.#resumePc = null;
140
+ this.sound.clear();
141
+ this.keyboard.clear();
142
+ }
143
+
144
+ /**
145
+ * Internal: a restore starts a fresh timeline, so the PC where the last run stopped for a
146
+ * breakpoint is forgotten, and the key queue and sound ring (whose cycle stamps belong to the
147
+ * old timeline) are cleared.
148
+ */
149
+ newTimeline() {
150
+ this.#resumePc = null;
151
+ this.sound.clear();
152
+ this.keyboard.clear();
153
+ }
154
+
155
+ /**
156
+ * The internal per-instruction hook, run at every instruction boundary (after interrupt
157
+ * delivery, before the instruction executes) by both `step` and `run`: deliver the matrix key
158
+ * events now due, then, with a fed character waiting and the PC at the ROM's keyboard scan
159
+ * ($002B) or wait-for-key ($0049), return from that routine with the character as the ROM
160
+ * would (spec §6, "Keyboard-scan hook"). That return stands in for the boundary's instruction,
161
+ * as one RET (10 T-states); a halted CPU idles instead, as it does for any instruction, so the next boundary is at the caller's return address.
162
+ * Between draining and the keyboard hook, a breakpoint hit (only while `debug.active`, and
163
+ * never in `step`) stops the boundary: nothing executes. Last, with memory accesses watched or
164
+ * hooked, the debugger is told an instruction starts at PC, so its fetches are not taken for
165
+ * reads and its accesses carry its PC; the keyboard hook's stand-in and a halted CPU's idle
166
+ * start none (the idle reads nothing; the stand-in's stack reads carry the CPU's PC).
167
+ * @returns {boolean | 'stop'} true when the hook stood in for the instruction, 'stop' when a breakpoint holds the boundary
168
+ */
169
+ #beforeInstruction() {
170
+ const cpu = this.system.cpu;
171
+ const kb = this.keyboard;
172
+ if (cpu.cycles >= kb.nextAt) kb.drain(cpu.cycles);
173
+ // A run's first boundary skips the check only at the PC its predecessor stopped at; a halted
174
+ // CPU idles at HALT+1 on every boundary, so it is never checked.
175
+ const pc = cpu.registers.PC;
176
+ const first = this.#firstBoundary && pc === this.#resumePc;
177
+ this.#firstBoundary = false;
178
+ this.#resumePc = null;
179
+ if (this.debug.active && this.#checkBreaks && !cpu.halted && this.debug.beforeInstruction(pc, first)) {
180
+ this.#stopped = true;
181
+ this.#resumePc = pc;
182
+ return 'stop';
183
+ }
184
+ if (cpu.halted) return false;
185
+ if (kb.hasFeed && (pc === 0x002b || pc === 0x0049)) {
186
+ const debug = this.debug;
187
+ if (debug.tracksAccess) {
188
+ debug.standIn(true);
189
+ try {
190
+ romKeyReturn(cpu, pc, kb.takeFeed());
191
+ } finally {
192
+ debug.standIn(false);
193
+ }
194
+ } else romKeyReturn(cpu, pc, kb.takeFeed());
195
+ return true;
196
+ }
197
+ if (this.debug.startsInstruction) this.debug.startInstruction(pc);
198
+ return false;
199
+ }
200
+
201
+ /** Bound once, so the hot loop passes the same function every instruction. */
202
+ #hook = () => this.#beforeInstruction();
203
+
204
+ /**
205
+ * Execute `n` whole instructions (an interrupt response counts with the instruction after it).
206
+ * @param {number} [n]
207
+ * @returns {{ cycles: number, halted: boolean }}
208
+ */
209
+ step(n = 1) {
210
+ const sys = this.system;
211
+ const start = sys.cpu.cycles;
212
+ const debug = this.debug;
213
+ if (this.#busy) throw new Error('step: the machine is already running; step and run cannot be called from a when or until callback');
214
+ this.#busy = true;
215
+ this.#checkBreaks = false;
216
+ try {
217
+ for (let k = 0; k < n; k++) {
218
+ sys.stepInstruction(this.#hook);
219
+ if (debug.active) debug.afterInstruction();
220
+ }
221
+ } catch (e) {
222
+ // A hook or callback that threw leaves no instruction open, no edge recorded for it, and no stop pending for the next call.
223
+ if (debug.active) {
224
+ debug.endInstruction();
225
+ debug.afterInstruction();
226
+ }
227
+ throw e;
228
+ } finally {
229
+ this.#busy = false;
230
+ }
231
+ return { cycles: sys.cpu.cycles - start, halted: sys.cpu.halted };
232
+ }
233
+
234
+ /**
235
+ * Run until a T-state budget is spent or a condition holds, both checked after each
236
+ * instruction; at least one instruction always runs. The condition wins a tie with the budget.
237
+ * A breakpoint stops the run before its instruction executes, and the next run starts past it
238
+ * (only at that PC, so hit counts do not depend on how a session is split into runs). A
239
+ * breakpoint at a run's first boundary that it did not just stop at stops with `cycles: 0`
240
+ * (an interrupt response at that boundary counts). Breakpoints are not checked while halted.
241
+ * A watch with `stop` returns after the instruction whose access hit it; after an instruction
242
+ * the order is watch stop, then `until`, then the budget. `step` logs watch hits but never stops.
243
+ * A stop watch hit by an interrupt response at a boundary a breakpoint then holds stays pending:
244
+ * the next run returns `'watch'` after the held instruction (a `step` in between drops it).
245
+ * @param {{ cycles?: number, until?: Until }} opts
246
+ * @returns {{ reason: 'cycles' | 'halt' | 'predicate', cycles: number } | { reason: 'break' | 'watch', id: number, cycles: number }}
247
+ */
248
+ run({ cycles, until } = {}) {
249
+ if (cycles === undefined && until === undefined) throw new Error('run needs cycles, until, or both; with neither it would never stop');
250
+ if (cycles !== undefined && !(Number.isFinite(cycles) && cycles >= 0)) throw new Error(`run: cycles must be a finite number of T-states, 0 or more, not ${String(cycles)}`);
251
+ if (until !== undefined && until !== 'halt' && typeof until !== 'function') throw new Error(`run: until must be 'halt' or a function, not ${String(until)}`);
252
+ const sys = this.system;
253
+ const cpu = sys.cpu;
254
+ const start = cpu.cycles;
255
+ const target = cycles === undefined ? Infinity : start + cycles;
256
+ const debug = this.debug;
257
+ if (this.#busy) throw new Error('run: the machine is already running; step and run cannot be called from a when or until callback');
258
+ this.#busy = true;
259
+ this.#checkBreaks = true;
260
+ this.#firstBoundary = true;
261
+ this.#stopped = false;
262
+ try {
263
+ for (;;) {
264
+ sys.stepInstruction(this.#hook);
265
+ if (this.#stopped) {
266
+ this.#stopped = false;
267
+ // A watch stop from this boundary's interrupt response stays pending: the next run returns it after the held instruction.
268
+ debug.endInstruction();
269
+ const id = /** @type {number} */ (debug.stopId);
270
+ debug.stopId = null;
271
+ return { reason: 'break', id, cycles: cpu.cycles - start };
272
+ }
273
+ if (debug.active) {
274
+ const w = debug.afterInstruction();
275
+ if (w !== null) return { reason: 'watch', id: w, cycles: cpu.cycles - start };
276
+ }
277
+ if (until === 'halt') {
278
+ if (cpu.halted) return { reason: 'halt', cycles: cpu.cycles - start };
279
+ } else if (until !== undefined && until(this)) {
280
+ return { reason: 'predicate', cycles: cpu.cycles - start };
281
+ }
282
+ if (cpu.cycles >= target) return { reason: 'cycles', cycles: cpu.cycles - start };
283
+ }
284
+ } catch (e) {
285
+ // A hook or callback that threw leaves no instruction open, no edge recorded for it, and no stop pending for the next call.
286
+ if (debug.active) {
287
+ debug.endInstruction();
288
+ debug.afterInstruction();
289
+ }
290
+ throw e;
291
+ } finally {
292
+ this.#busy = false;
293
+ }
294
+ }
295
+ }