@fugloy/emu 0.2.0 → 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/machine/io.js CHANGED
@@ -7,20 +7,43 @@
7
7
  * 0xEC-0xEF read: acknowledge/clear the RTC interrupt
8
8
  * write: mode register (bit 1 = cassette motor,
9
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
10
+ * 0xE4-0xE7 read: NMI status, active-low (bit 7 = FDC INTRQ)
11
+ * write: NMI mask (bit 7 = FDC INTRQ raises NMI)
12
+ * 0xF0-0xF3 WD1793 floppy controller; with no disk mounted anywhere it
13
+ * is absent and reads float to 0xFF
14
+ * 0xF4 drive select (write): bits 0-3 drive, bit 4 side, bit 6
15
+ * WAIT, bit 7 MFM
12
16
  * 0xF8-0xFB line printer (read: status, write: data byte)
13
17
  * 0xFF cassette port (write bits 0-1 = output level,
14
- * read bit 7 = tape input)
18
+ * read: bit 7 = 500-baud pulse latch, bit 0 = 1500-baud
19
+ * level, bits 5-1 = port 0xEC's latch, bit 6 high)
15
20
  *
16
21
  * The RTC heartbeat interrupt fires at 30 Hz; its latch bit is RTC_IRQ_BIT.
17
22
  * The keyboard is memory-mapped at 0x3800-0x3BFF (see MemorySystem), not
18
23
  * an I/O port.
24
+ *
25
+ * The Model I has only port 0xFF (cassette, sound, and the video mode; its
26
+ * own handler, initializeModel1CassettePort): its other devices are
27
+ * memory-mapped by the Expansion Interface at 0x37E0-0x37EF
28
+ * (src/machine/models.js), which MemorySystem hands to `readExpansion`,
29
+ * `peekExpansion`, and `writeExpansion` here, so every other port floats to
30
+ * 0xFF. With the Expansion Interface (`expansion`, the default) the Model I
31
+ * has the interrupt latch at 0x37E0 (`latch`, devices/interrupt-latch.js),
32
+ * the drive select at 0x37E1, the printer at 0x37E8, and the WD1771 at
33
+ * 0x37EC-0x37EF (devices/fdc-wd1771.js, its INTRQ on the latch's bit 6);
34
+ * without it `latch` is null and nothing interrupts. The controller object
35
+ * exists on both models, clocked at the model's CPU clock (a WD1793 on the
36
+ * Model III, a WD1771 on the Model I); the Model I does not reach it through
37
+ * ports, and without the Expansion Interface not at all.
19
38
  */
20
39
 
21
40
  import { CassetteSystem } from "../devices/cassette.js";
22
41
  import { FDC1793 } from "../devices/fdc-wd1793.js";
42
+ import { FDC1771 } from "../devices/fdc-wd1771.js";
23
43
  import { Printer } from "../devices/printer.js";
44
+ import { InterruptLatch } from "../devices/interrupt-latch.js";
45
+ import { MODEL_III } from "./models.js";
46
+ import { decodeModel1Select, decodeModel3Select } from "./drive-select.js";
24
47
 
25
48
  export const RTC_IRQ_BIT = 0x04;
26
49
  // Cassette-edge interrupt sources (from the ROM's ISR at 0x35C2: E0
@@ -37,10 +60,32 @@ export const NMI_INTRQ_BIT = 0x80;
37
60
  // Mode register (port 0xEC write) bits
38
61
  export const MODE_32COL_BIT = 0x04; // 32-character display mode
39
62
 
63
+ // The Model I's port 0xFF output latch (Z59), D0-D3: "D0 and D1 ... are used
64
+ // to output data that is recorded on tape during a CSAVE function. D2 ...
65
+ // controls the status of the Recorder's motor. D3 ... controls the status of
66
+ // MODESEL*" (TRS-80 Micro Computer Technical Reference Handbook, 2nd ed.,
67
+ // 1982, p. 45, "OUTSIG*"). On the Model I `modeRegister` holds this latch.
68
+ export const M1_MOTOR_BIT = 0x04;
69
+ export const M1_MODESEL_BIT = 0x08; // 32-character display mode
70
+
71
+ export { decodeModel1Select, decodeModel3Select } from "./drive-select.js";
72
+
40
73
  export class IOSystem {
41
- constructor() {
74
+ /**
75
+ * @param {{ model?: import('./models.js').Model, expansion?: boolean }} [opts] the model whose
76
+ * ports to install (the Model III by default), and on the Model I whether the Expansion
77
+ * Interface is fitted (by default it is)
78
+ */
79
+ constructor({ model = MODEL_III, expansion = true } = {}) {
80
+ this.model = model;
81
+ // The Expansion Interface's interrupt latch at 0x37E0: the one object the timer (bit 7) and
82
+ // the disk controller (bit 6, emu phase 6 Task 3) raise and clear their bits in.
83
+ /** @type {InterruptLatch | null} */
84
+ this.latch = model.id === 1 && expansion ? new InterruptLatch() : null;
42
85
  this.cassette = new CassetteSystem();
43
- this.fdc = new FDC1793();
86
+ // A Model I recording decodes in Model III T-states (cassette.js)
87
+ if (model.id !== 3) this.cassette.timeScale = MODEL_III.clockHz / model.clockHz;
88
+ this.fdc = model.id === 3 ? new FDC1793({ cpuHz: model.clockHz }) : new FDC1771({ cpuHz: model.clockHz, latch: this.latch });
44
89
  this.printer = new Printer();
45
90
  this.keyboardBuffer = []; // legacy internal buffer (not a Model III port)
46
91
  this.portHandlers = new Map();
@@ -52,7 +97,8 @@ export class IOSystem {
52
97
  // NMI mask (port 0xE4 write): bit 7 = FDC INTRQ triggers NMI
53
98
  this.nmiMask = 0;
54
99
 
55
- // Mode register (port 0xEC write)
100
+ // Mode register (port 0xEC write); on the Model I, the port 0xFF
101
+ // output latch, D0-D3 (M1_MOTOR_BIT, M1_MODESEL_BIT)
56
102
  this.modeRegister = 0;
57
103
 
58
104
  // Fired when a mode-register write changes the value (the display
@@ -74,8 +120,21 @@ export class IOSystem {
74
120
  this.onSoundWrite = null;
75
121
  // Called when the timeline restarts (state restore): the Machine clears its sound ring.
76
122
  this.onSoundReset = null;
123
+ // The Model III WAIT (port F4 bit 6): called with a function that runs the controller to
124
+ // its next DRQ or INTRQ and returns the T-states that took, for the CPU to wait them; wired
125
+ // by TRS80System. Unwired, port F4 does not wait.
126
+ this.onWait = null;
77
127
 
78
- this.initializeModelIIIPorts();
128
+ if (model.id === 3) {
129
+ this.initializeModelIIIPorts();
130
+ this.initializeCassettePort();
131
+ } else {
132
+ this.initializeModel1CassettePort();
133
+ // The Model I interrupts through the Expansion Interface's latch alone, which has no mask
134
+ // (none without the interface); the Model III's pendingInterrupt below is left as it was.
135
+ const latch = this.latch;
136
+ this.pendingInterrupt = latch === null ? () => false : () => latch.bits !== 0;
137
+ }
79
138
  }
80
139
 
81
140
  initializeModelIIIPorts() {
@@ -114,6 +173,10 @@ export class IOSystem {
114
173
 
115
174
  // 0xE4-0xE7: disk NMI mask (write) / status (read, active-low).
116
175
  // With no controller "present" (no disk mounted) the port floats.
176
+ // Bit 7 is the FDC's INTRQ, read live. MAME's trs80m3 driver also
177
+ // names bit 6 (motor timeout, its own NMI enable) and bit 5 (the reset
178
+ // button); neither is modeled, and none of this is yet checked against
179
+ // the Model III Technical Reference Manual (spec §5).
117
180
  for (let port = 0xe4; port <= 0xe7; port++) {
118
181
  this.portHandlers.set(port, {
119
182
  read: () => {
@@ -157,11 +220,17 @@ export class IOSystem {
157
220
  },
158
221
  });
159
222
 
160
- // 0xF4: drive select / side / density latch
223
+ // 0xF4: drive select / side / density latch. Bit 6 asserts WAIT
224
+ // until the controller raises DRQ or INTRQ (MAME's trs80m3: only when
225
+ // both are low); the ROM's boot read and the DOSes' INI loops rely on
226
+ // it to take each byte as it arrives.
161
227
  this.portHandlers.set(0xf4, {
162
228
  read: () => 0xff,
163
229
  write: (v) => {
164
- if (fdcPresent()) this.fdc.selectDrive(v);
230
+ if (!fdcPresent()) return;
231
+ const sel = decodeModel3Select(v);
232
+ this.fdc.select(sel.drive, sel.side, sel.mfm);
233
+ if (sel.wait && this.onWait) this.onWait(() => this.fdc.waitForDrq());
165
234
  },
166
235
  });
167
236
 
@@ -174,7 +243,10 @@ export class IOSystem {
174
243
  write: (v) => this.printer.write(v),
175
244
  });
176
245
  }
246
+ }
177
247
 
248
+ /** Port 0xFF on the Model III. */
249
+ initializeCassettePort() {
178
250
  // 0xFF: cassette data port. Reads return the input latch in bit 7
179
251
  // (set by tape transitions while the motor runs); ANY write clears
180
252
  // the latch — that's the Model III's latch circuit, and it's how
@@ -190,11 +262,15 @@ export class IOSystem {
190
262
  // does IN A,(0xFF) / AND 0x01 / CP E per edge);
191
263
  // bit 7 — a rising-edge PULSE LATCH (the 500-baud reader
192
264
  // polls it with RLA and resets it by OUT (0xFF),A).
265
+ // Bits 5-1 read port 0xEC's latch back (DISWAIT, ENEXTIO, ENALTSET,
266
+ // MODSEL, CASMOTORON), and bit 6 is undefined: Model III Technical
267
+ // Reference Manual p. 22 (CASIN*). Undriven, bit 6 floats high, as
268
+ // an absent port's bits do; trs80gp (and trsjs) read it as 1 too.
193
269
  // Reading also acks the edge interrupt sources: the ISR reads
194
270
  // 0xE0 then 0xFF and nothing else.
195
271
  this.intLatch &= ~(CASSETTE_RISE_IRQ_BIT | CASSETTE_FALL_IRQ_BIT);
196
272
  const level = this.cassette.tapeLevel(cycles);
197
- return (this.cassette.pulseLatch ? 0x80 : 0x00) | (level ? 0x01 : 0x00);
273
+ return (this.cassette.pulseLatch ? 0x80 : 0x00) | 0x40 | (this.modeRegister & 0x3e) | (level ? 0x01 : 0x00);
198
274
  },
199
275
  write: (value) => {
200
276
  const cycles = this.getCycles ? this.getCycles() : 0;
@@ -212,6 +288,156 @@ export class IOSystem {
212
288
  });
213
289
  }
214
290
 
291
+ /**
292
+ * Port 0xFF on the Model I. The port is the Z59 output latch and one input
293
+ * flip-flop (Technical Reference Handbook, 2nd ed., p. 45, "OUTSIG*",
294
+ * and pp. 50-51, "INSIG*"):
295
+ * write D0-D1 cassette output level (the only sound source; logged for
296
+ * the sound driver and m.sound, and recorded while the
297
+ * motor runs, as on the Model III)
298
+ * write D2 the cassette motor relay
299
+ * write D3 MODESEL: 32 characters a line when set
300
+ * any write resets the cassette flip-flop ("set by cassette data and
301
+ * reset by OUTSIG*", p. 50)
302
+ * read D7 the cassette flip-flop: the Level II ROM's bit reader
303
+ * (MDL1LEV2.Z80, CTBIT at $0241) polls it with RLA and resets
304
+ * it through CTSTAT ($021E), an OUT of the shadow copy CAST
305
+ * The other read bits are MAME's (trs80 driver, port_ff_r: D6 is MODESEL
306
+ * inverted, D0-D5 read 1); the handbook says only that INSIG* enables the
307
+ * flip-flop's buffer, so they are unverified. The deck sets the flip-flop
308
+ * on a rising edge of the tape signal (cassette.js); MAME takes the
309
+ * falling edge, which for the 500-baud pulses moves nothing a reader sees.
310
+ */
311
+ initializeModel1CassettePort() {
312
+ this.portHandlers.set(0xff, {
313
+ read: () => {
314
+ const cycles = this.getCycles ? this.getCycles() : 0;
315
+ this.cassette.tapeLevel(cycles); // play the tape up to now
316
+ return (
317
+ (this.cassette.pulseLatch ? 0x80 : 0x00) |
318
+ (this.modeRegister & M1_MODESEL_BIT ? 0x00 : 0x40) |
319
+ 0x3f
320
+ );
321
+ },
322
+ write: (value) => {
323
+ const cycles = this.getCycles ? this.getCycles() : 0;
324
+ if (this.onSoundWrite) this.onSoundWrite(cycles, value);
325
+ const motor = (value & M1_MOTOR_BIT) !== 0;
326
+ if (motor !== this.cassette.motorOn) {
327
+ this.cassette.control(motor ? 1 : 0, cycles);
328
+ }
329
+ const level = value & 0x03;
330
+ if (level !== this.cassetteOut) {
331
+ this.cassetteOut = level;
332
+ if (this.getCycles && this.soundLog.length < this.SOUND_LOG_LIMIT) {
333
+ this.soundLog.push({ t: cycles, level });
334
+ }
335
+ this.cassette.recordTransition(cycles, level);
336
+ }
337
+ const latch = value & 0x0f;
338
+ const changed = (latch ^ this.modeRegister) & M1_MODESEL_BIT;
339
+ this.modeRegister = latch;
340
+ if (changed && this.onModeWrite) this.onModeWrite(this.modeRegister);
341
+ this.cassette.clearPulseLatch(cycles);
342
+ },
343
+ });
344
+ }
345
+
346
+ /**
347
+ * A CPU read of the Model I's Expansion Interface addresses 0x37E0-0x37EF
348
+ * (MemorySystem calls it only on a Model I with the Expansion Interface):
349
+ * 0x37E0-0x37E3 the interrupt latch, clearing its clock bit
350
+ * (interrupt-latch.js; the mirrors are MAME's, unverified)
351
+ * 0x37E4-0x37E7 0xFF (the cassette select at 0x37E4 is write-only, and a
352
+ * second deck is not modelled)
353
+ * 0x37E8-0x37EB the printer's status: the Level II ROM's PRTAVL at $05D1
354
+ * reads 0x37E8 and takes (status AND F0H) = 30H as ready
355
+ * (MDL1LEV2.Z80); the mirrors are MAME's (unverified)
356
+ * 0x37EC-0x37EF the WD1771: status, track, sector, data (CONSTANTS.Z80,
357
+ * "Memory Mapped Hardware, Model 1"); a status read clears
358
+ * INTRQ and with it the latch's disk bit
359
+ * With no disk mounted in any drive the controller is taken as absent, as on
360
+ * the Model III, and 0x37EC-0x37EF read 0xFF: the ROM's test at $0696-$069C
361
+ * (status + 1 < 2, that is $FF or $00: no disk controller) then sends it to
362
+ * Level II BASIC instead of a boot that would wait for a disk forever.
363
+ * @param {number} address
364
+ */
365
+ readExpansion(address) {
366
+ if (address <= 0x37e3) return this.latch.read();
367
+ if (address >= 0x37ec) {
368
+ if (!this.fdc.anyDiskMounted()) return 0xff;
369
+ switch (address) {
370
+ case 0x37ec:
371
+ return this.fdc.readStatus();
372
+ case 0x37ed:
373
+ return this.fdc.readTrackReg();
374
+ case 0x37ee:
375
+ return this.fdc.readSectorReg();
376
+ default:
377
+ return this.fdc.readData();
378
+ }
379
+ }
380
+ if (address >= 0x37e8) return this.printer.status();
381
+ return 0xff;
382
+ }
383
+
384
+ /**
385
+ * What `readExpansion` would return, with no side effect (the latch keeps
386
+ * its clock bit, the controller its INTRQ and DRQ).
387
+ * @param {number} address
388
+ */
389
+ peekExpansion(address) {
390
+ if (address <= 0x37e3) return this.latch.peek();
391
+ if (address >= 0x37ec) {
392
+ if (!this.fdc.anyDiskMounted()) return 0xff;
393
+ if (address === 0x37ec) return this.fdc.peekStatus();
394
+ if (address === 0x37ed) return this.fdc.trackReg;
395
+ if (address === 0x37ee) return this.fdc.sectorReg;
396
+ return this.fdc.dataReg;
397
+ }
398
+ if (address >= 0x37e8) return this.printer.status();
399
+ return 0xff;
400
+ }
401
+
402
+ /**
403
+ * A CPU write to 0x37E0-0x37EF:
404
+ * 0x37E0-0x37E3 the drive select (decodeModel1Select; written at 0x37E1,
405
+ * the mirrors are MAME's, unverified). The latch is the
406
+ * Expansion Interface's, not the controller's, so it takes
407
+ * a write with no disk mounted too, and a disk mounted
408
+ * later finds the drive the program last selected
409
+ * 0x37E8-0x37EB a byte to the printer (the ROM's `LD (37E8H),A` at $05BB)
410
+ * 0x37EC-0x37EF the WD1771: command, track, sector, data
411
+ * The controller's registers ignore writes while no disk is mounted
412
+ * (absent, as above).
413
+ * @param {number} address
414
+ * @param {number} value
415
+ */
416
+ writeExpansion(address, value) {
417
+ if (address <= 0x37e3) {
418
+ this.fdc.select(decodeModel1Select(value).drive, 0, false);
419
+ return;
420
+ }
421
+ if (address >= 0x37ec) {
422
+ if (!this.fdc.anyDiskMounted()) return;
423
+ switch (address) {
424
+ case 0x37ec:
425
+ this.fdc.writeCommand(value);
426
+ return;
427
+ case 0x37ed:
428
+ this.fdc.writeTrackReg(value);
429
+ return;
430
+ case 0x37ee:
431
+ this.fdc.writeSectorReg(value);
432
+ return;
433
+ default:
434
+ this.fdc.writeData(value);
435
+ }
436
+ return;
437
+ }
438
+ if (address >= 0x37e8) this.printer.write(value);
439
+ }
440
+
215
441
  readPort(port) {
216
442
  port &= 0xff;
217
443
  const handler = this.portHandlers.get(port);
@@ -261,17 +487,14 @@ export class IOSystem {
261
487
 
262
488
  /** Clear interrupt state (system reset). Mounted disks stay mounted. */
263
489
  reset() {
490
+ this.latch?.reset();
264
491
  this.intLatch = 0;
265
492
  this.intMask = 0;
266
493
  this.nmiMask = 0;
267
494
  this.modeRegister = 0;
268
495
  this.cassetteOut = 0;
269
496
  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;
497
+ this.fdc.reset();
275
498
  }
276
499
 
277
500
  readKeyboard() {
@@ -1,15 +1,17 @@
1
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.
2
+ * The spec §3 `Machine`: the public face of the emulator, over a TRS80System. It builds the
3
+ * Model III with 48K, or (emu phase 6) the Model I with 4, 16, 32, or 48K, as the model
4
+ * descriptions in models.js lay them out; execution, state access, ROM identification, the
5
+ * devices, the loaders, and the debugger live here.
5
6
  */
6
7
  import { TRS80System } from './trs80-system.js';
7
8
  import { ROM_HASHES } from './rom-hashes.js';
9
+ import { modelOf, ramProblem, romModelProblem } from './models.js';
8
10
  import { sha256Hex } from './sha256.js';
9
11
  import { Video } from '../devices/video.js';
10
12
  import { Sound } from '../devices/sound.js';
11
13
  import { loadBas } from '../loaders/bas.js';
12
- import { cassetteDeck, loadCas, loadCmd } from '../loaders/load.js';
14
+ import { cassetteDeck, diskDrives, loadCas, loadCmd, loadDsk } from '../loaders/load.js';
13
15
  import { Debugger } from '../debug/debugger.js';
14
16
  import { KeyQueue, romKeyReturn } from '../devices/key-queue.js';
15
17
 
@@ -41,12 +43,15 @@ export class Machine {
41
43
  keyboard;
42
44
  /**
43
45
  * @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> }}
46
+ * cas(bytes: Uint8Array, opts?: { baud?: 500 | 1500, via?: 'rom' | 'fast' }): ReturnType<typeof loadCas>,
47
+ * dsk(bytes: Uint8Array, opts?: { drive?: number, boot?: boolean }): ReturnType<typeof loadDsk> }}
45
48
  * Program loaders; each returns a manifest of where the bytes land and why.
46
49
  */
47
50
  load;
48
51
  /** @type {ReturnType<typeof cassetteDeck>} The cassette deck: insert a .CAS at a baud, rewind, read the motor relay. */
49
52
  cassette;
53
+ /** @type {ReturnType<typeof diskDrives>} The four floppy drives: mount a JV1, JV3, or DMK image, unmount, list what is in each, export one as DMK. */
54
+ fdc;
50
55
  /** @type {Debugger} Breakpoints, watchpoints, hooks, coverage, the instruction trace, snapshots, labels, and the label file (spec §7, §8). */
51
56
  debug;
52
57
 
@@ -62,18 +67,32 @@ export class Machine {
62
67
  #stopped = false;
63
68
 
64
69
  /**
65
- * @param {{ model?: 3, ram?: 48, roms: { level2: Uint8Array }, log?: (level: string, text: string) => void }} opts
70
+ * @param {{ model?: 1 | 3, ram?: 4 | 16 | 32 | 48, roms: { level2: Uint8Array }, expansion?: boolean, lowercase?: boolean,
71
+ * log?: (level: string, text: string) => void }} opts
72
+ * `ram` defaults to 48 (the Model III takes only 48 for now); `expansion` (the Expansion Interface, default
73
+ * true) and `lowercase` (the lowercase modification, default false) are Model I options
66
74
  */
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)`);
75
+ constructor({ model = 3, ram, roms, expansion, lowercase, log } = /** @type {any} */ ({})) {
76
+ const desc = modelOf(model);
77
+ const kb = ram ?? desc.defaultRam;
78
+ const badRam = ramProblem(desc, kb);
79
+ if (badRam !== null) throw new Error(badRam);
80
+ for (const [name, value] of /** @type {const} */ ([['expansion', expansion], ['lowercase', lowercase]])) {
81
+ if (value === undefined) continue;
82
+ if (desc.id !== 1) throw new Error(`${name} is a Model I option; the ${desc.name} has no ${name === 'expansion' ? 'Expansion Interface' : 'lowercase modification (its lowercase is built in)'}.`);
83
+ if (typeof value !== 'boolean') throw new Error(`${name} must be true or false, not ${String(value)}.`);
70
84
  }
71
85
  const level2 = roms?.level2;
72
86
  if (!ArrayBuffer.isView(level2) || level2.BYTES_PER_ELEMENT !== 1) throw new Error('Machine needs roms.level2, the ROM image as a Uint8Array');
73
87
 
74
- this.system = new TRS80System({ romData: level2, log });
88
+ // A ROM recognised as the other model's is refused by name before its size is checked, so the
89
+ // sentence says which model it needs rather than only that its length is wrong.
75
90
  const sha256 = sha256Hex(level2);
76
- this.rom = { bytes: level2.length, sha256, recognized: ROM_HASHES[sha256] ?? null };
91
+ const recognized = ROM_HASHES[sha256] ?? null;
92
+ const problem = romModelProblem(recognized, desc.id);
93
+ if (problem !== null) throw new Error(problem);
94
+ this.system = new TRS80System({ romData: level2, log, model: desc, ram: kb, expansion: expansion ?? true, lowercase: lowercase ?? false });
95
+ this.rom = { bytes: level2.length, sha256, recognized };
77
96
 
78
97
  const sys = this.system;
79
98
  const cpu = sys.cpu;
@@ -95,7 +114,8 @@ export class Machine {
95
114
  this.mem = {
96
115
  read(addr, len) {
97
116
  const out = new Uint8Array(len);
98
- for (let k = 0; k < len; k++) out[k] = sys.memory.readByte((addr + k) & 0xffff);
117
+ // Peeked: reading the Model I's interrupt latch here does not clear it, as a CPU read would.
118
+ for (let k = 0; k < len; k++) out[k] = sys.memory.peekByte((addr + k) & 0xffff);
99
119
  return out;
100
120
  },
101
121
  write(addr, bytes) {
@@ -109,7 +129,7 @@ export class Machine {
109
129
  this.sound = new Sound();
110
130
  sys.io.onSoundWrite = (cycle, value) => this.sound.record(cycle, value);
111
131
  sys.io.onSoundReset = () => this.sound.clear();
112
- this.keyboard = new KeyQueue(sys.keyboard, () => sys.cpu.cycles);
132
+ this.keyboard = new KeyQueue(sys.keyboard, () => sys.cpu.cycles, sys.clockHz);
113
133
  this.debug = new Debugger(this);
114
134
  // Each load records its manifest and the bytes' hash for the label file (the last one wins).
115
135
  const record = (/** @type {any} */ manifest, /** @type {Uint8Array} */ bytes) => {
@@ -120,14 +140,30 @@ export class Machine {
120
140
  bas: (bytes) => record(loadBas(this, bytes), bytes),
121
141
  cmd: (bytes) => record(loadCmd(this, bytes), bytes),
122
142
  cas: (bytes, opts) => record(loadCas(this, bytes, opts), bytes),
143
+ dsk: (bytes, opts) => record(loadDsk(this, bytes, opts), bytes),
123
144
  };
124
145
  this.cassette = cassetteDeck(this);
146
+ this.fdc = diskDrives(this);
125
147
  this.io = {
126
148
  in: (port) => sys.io.readPort(port),
127
149
  out: (port, value) => sys.io.writePort(port, value),
128
150
  };
129
151
  }
130
152
 
153
+ /** @returns {1 | 3} The model built: 1 (the Model I) or 3 (the Model III). */
154
+ get model() {
155
+ return this.system.model.id;
156
+ }
157
+
158
+ /**
159
+ * The model's CPU clock in Hz: 2,027,520 on the Model III, 1,774,080 on the Model I (models.js).
160
+ * T-state counts (`cycles`, `run`'s budget, `m.sound` stamps) are in this clock's states.
161
+ * @returns {number}
162
+ */
163
+ get clockHz() {
164
+ return this.system.clockHz;
165
+ }
166
+
131
167
  /** Total T-states since the last reset. */
132
168
  get cycles() {
133
169
  return this.system.cpu.cycles;
@@ -157,8 +193,10 @@ export class Machine {
157
193
  * delivery, before the instruction executes) by both `step` and `run`: deliver the matrix key
158
194
  * events now due, then, with a fed character waiting and the PC at the ROM's keyboard scan
159
195
  * ($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.
196
+ * would (spec §6, "Keyboard-scan hook"). The Model I Level II ROM has the same two entries with
197
+ * the same contract (romKeyReturn), so the hook serves both models. That return stands in for
198
+ * the boundary's instruction, as one RET (10 T-states); a halted CPU idles instead, as it does
199
+ * for any instruction, so the next boundary is at the caller's return address.
162
200
  * Between draining and the keyboard hook, a breakpoint hit (only while `debug.active`, and
163
201
  * never in `step`) stops the boundary: nothing executes. Last, with memory accesses watched or
164
202
  * hooked, the debugger is told an instruction starts at PC, so its fetches are not taken for