@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.
- package/LICENSE +21 -0
- package/NOTICE.md +27 -0
- package/README.md +119 -2
- package/package.json +30 -3
- package/src/cpu/z80cpu.js +3343 -0
- package/src/debug/debugger.js +777 -0
- package/src/debug/instr.js +90 -0
- package/src/debug/label-file.js +423 -0
- package/src/debug/state.js +214 -0
- package/src/devices/cassette-tape.js +254 -0
- package/src/devices/cassette.js +259 -0
- package/src/devices/disk-image.js +578 -0
- package/src/devices/fdc-wd1793.js +466 -0
- package/src/devices/key-queue.js +261 -0
- package/src/devices/keyboard.js +176 -0
- package/src/devices/printer.js +44 -0
- package/src/devices/sound.js +53 -0
- package/src/devices/video.js +66 -0
- package/src/index.js +22 -0
- package/src/loaders/bas.js +107 -0
- package/src/loaders/cas-format.js +213 -0
- package/src/loaders/cmd-format.js +88 -0
- package/src/loaders/handoff.js +29 -0
- package/src/loaders/load.js +135 -0
- package/src/machine/io.js +290 -0
- package/src/machine/machine.js +295 -0
- package/src/machine/memory.js +207 -0
- package/src/machine/rom-hashes.js +14 -0
- package/src/machine/sha256.js +74 -0
- package/src/machine/trs80-system.js +232 -0
|
@@ -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
|
+
}
|