@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.
@@ -1,26 +1,70 @@
1
1
  /**
2
- * TRS-80 Model III Memory Management System
2
+ * TRS-80 memory map, by model (src/machine/models.js holds the sources for the Model I facts):
3
3
  *
4
- * Real Model III address map (non-disk machine):
5
- * 0x0000-0x37FF 14K ROM (write-protected)
6
- * 0x3800-0x3BFF keyboard matrix (memory-mapped, read-only;
7
- * low 8 address bits select rows, A8/A9 undecoded)
8
- * 0x3C00-0x3FFF 1K video RAM (64 cols x 16 rows)
9
- * 0x4000-0xFFFF 48K RAM
4
+ * Model III (non-disk machine):
5
+ * 0x0000-0x37FF 14K ROM (write-protected)
6
+ * 0x3800-0x3BFF keyboard matrix (memory-mapped, read-only;
7
+ * low 8 address bits select rows, A8/A9 undecoded)
8
+ * 0x3C00-0x3FFF 1K video RAM (64 cols x 16 rows)
9
+ * 0x4000-0xFFFF 48K RAM
10
+ *
11
+ * Model I:
12
+ * 0x0000-0x2FFF 12K Level II ROM (write-protected)
13
+ * 0x3000-0x37DF nothing: reads float to 0xFF, writes are lost
14
+ * 0x37E0-0x37EF the Expansion Interface (IOSystem.readExpansion and
15
+ * writeExpansion: the interrupt latch at 0x37E0, the drive
16
+ * select at 0x37E1, the printer at 0x37E8, the WD1771 at
17
+ * 0x37EC-0x37EF); without it, nothing, as 0x3000-0x37DF
18
+ * 0x3800-0x3BFF keyboard matrix, as on the Model III
19
+ * 0x3C00-0x3FFF 1K video RAM, seven bits wide (below)
20
+ * 0x4000-... 4, 16, 32, or 48K RAM; above it reads float to 0xFF
21
+ * and writes are lost
22
+ *
23
+ * The stock Model I's video RAM has no bit 6: "There are seven RAMs. Six
24
+ * are used for ASCII storage, and the seventh is used as a graphic/
25
+ * alphanumeric definition bit. ... Notice the line labeled Bit 6. It is
26
+ * sourced by NOR gate Z30 at pin 13. If Bit 5 ... and Bit 7 ... are low,
27
+ * then Bit 6 will be high" (TRS-80 Micro Computer Technical Reference
28
+ * Handbook, 2nd ed., 1982, p. 34, "Video RAMs"). A byte written there is
29
+ * kept as it reads back and displays: bit 6 = NOT (bit 5 OR bit 7). The
30
+ * lowercase modification (`lowercase: true`) fits the missing RAM, so all
31
+ * eight bits are kept.
10
32
  */
11
33
 
12
- const ROM_LIMIT = 0x3800;
34
+ import { MODEL_III } from "./models.js";
35
+
36
+ const KEYBOARD_BASE = 0x3800;
13
37
  const KEYBOARD_LIMIT = 0x3c00;
14
38
  const RAM_BASE = 0x4000;
39
+ const EXPANSION_BASE = 0x37e0;
40
+ const EXPANSION_LIMIT = 0x37f0;
15
41
 
16
42
  export class MemorySystem {
17
- constructor() {
43
+ /**
44
+ * @param {{ model?: import('./models.js').Model, ram?: number, lowercase?: boolean }} [opts] the
45
+ * model (the Model III by default), its RAM in K (checked by the Machine against the model's
46
+ * sizes), and on the Model I whether the lowercase modification is fitted (not by default)
47
+ */
48
+ constructor({ model = MODEL_III, ram = model.defaultRam, lowercase = false } = {}) {
49
+ this.model = model;
50
+
51
+ // The stock Model I's seven-bit video RAM (above); the Model III's and a
52
+ // lowercase-modified Model I's keep all eight bits.
53
+ this.videoBit6Gate = model.id === 1 && !lowercase;
54
+
55
+ // The Model I's Expansion Interface at 0x37E0-0x37EF: the IOSystem, set by
56
+ // TRS80System when the interface is fitted; null, those addresses read 0xFF.
57
+ /** @type {{ readExpansion(a: number): number, peekExpansion(a: number): number, writeExpansion(a: number, v: number): void } | null} */
58
+ this.expansion = null;
59
+
18
60
  // ROM storage is 16K so padded 16K images can be loaded verbatim,
19
- // but only 0x0000-0x37FF is ever mapped as ROM.
61
+ // but only 0x0000 up to the model's ROM top is ever mapped as ROM.
20
62
  this.rom = new Uint8Array(0x4000);
63
+ this.romLimit = model.romTop;
21
64
 
22
- // 48K RAM (0x4000 - 0xFFFF)
23
- this.ram = new Uint8Array(0xc000);
65
+ // RAM from 0x4000: 48K reaches 0xFFFF
66
+ this.ram = new Uint8Array(ram * 1024);
67
+ this.ramLimit = RAM_BASE + this.ram.length;
24
68
 
25
69
  // 1K video RAM (0x3C00 - 0x3FFF)
26
70
  this.videoRam = new Uint8Array(0x400);
@@ -51,18 +95,19 @@ export class MemorySystem {
51
95
  }
52
96
 
53
97
  /**
54
- * Load a ROM image: the real 14K (0x3800) Model III ROM, or a 16K
55
- * (0x4000) padded image of it. Anything else is a wrong file — reject
56
- * it rather than guessing.
98
+ * Load a ROM image of a size the model takes: the Model III's real 14K
99
+ * (0x3800) ROM or a 16K (0x4000) padded image of it; the Model I's 12K
100
+ * (0x3000) Level II ROM. Anything else is a wrong file — reject it
101
+ * rather than guessing.
57
102
  */
58
103
  loadROM(romData) {
59
104
  if (!romData || romData.length === 0) {
60
105
  throw new Error("ROM data is required");
61
106
  }
62
107
 
63
- if (romData.length !== 0x3800 && romData.length !== 0x4000) {
108
+ if (!this.model.romSizes.includes(romData.length)) {
64
109
  throw new Error(
65
- `Invalid ROM size: ${romData.length} bytes. Expected 14KB (0x3800) or a 16KB (0x4000) padded image`
110
+ `Invalid ROM size: ${romData.length} bytes. ${this.model.romSizeText}`
66
111
  );
67
112
  }
68
113
 
@@ -80,9 +125,16 @@ export class MemorySystem {
80
125
  readByte(address) {
81
126
  address = address & 0xffff;
82
127
 
83
- if (address < ROM_LIMIT) {
128
+ if (address < this.romLimit) {
84
129
  return this.rom[address];
85
130
  }
131
+ if (address < KEYBOARD_BASE) {
132
+ // The Model I's 0x3000-0x37FF (models.js): the Expansion Interface, or nothing
133
+ if (address >= EXPANSION_BASE && address < EXPANSION_LIMIT && this.expansion !== null) {
134
+ return this.expansion.readExpansion(address);
135
+ }
136
+ return 0xff;
137
+ }
86
138
  if (address < KEYBOARD_LIMIT) {
87
139
  // Keyboard matrix: reading with no keys down yields 0x00
88
140
  return this.keyboard ? this.keyboard.read(address & 0xff) : 0x00;
@@ -90,26 +142,51 @@ export class MemorySystem {
90
142
  if (address < RAM_BASE) {
91
143
  return this.videoRam[address - KEYBOARD_LIMIT];
92
144
  }
93
- return this.ram[address - RAM_BASE];
145
+ if (address < this.ramLimit) {
146
+ return this.ram[address - RAM_BASE];
147
+ }
148
+ return 0xff; // above the fitted RAM (models.js)
149
+ }
150
+
151
+ /**
152
+ * Read a byte as `readByte` does, without a device's read side effect: the
153
+ * Model I's Expansion Interface addresses are peeked (reading the interrupt
154
+ * latch at 0x37E0 does not clear it). For the debugger and `m.mem.read`.
155
+ */
156
+ peekByte(address) {
157
+ address = address & 0xffff;
158
+ if (address >= EXPANSION_BASE && address < EXPANSION_LIMIT && this.expansion !== null) {
159
+ return this.expansion.peekExpansion(address);
160
+ }
161
+ return this.readByte(address);
94
162
  }
95
163
 
96
164
  /**
97
165
  * Write a byte to memory
98
- * ROM and the keyboard region ignore writes; video RAM and RAM accept them.
166
+ * ROM, the Model I's unmapped 0x3000-0x37FF, the keyboard region, and
167
+ * addresses above the fitted RAM ignore writes; video RAM and RAM accept them,
168
+ * and the Model I's Expansion Interface takes its own.
99
169
  */
100
170
  writeByte(address, value) {
101
171
  address = address & 0xffff;
102
172
  value = value & 0xff;
103
173
 
104
174
  if (address < KEYBOARD_LIMIT) {
105
- return; // ROM / keyboard matrix: not writable
175
+ // ROM / keyboard matrix: not writable; the Model I's Expansion Interface
176
+ if (this.expansion !== null && address >= EXPANSION_BASE && address < EXPANSION_LIMIT) {
177
+ this.expansion.writeExpansion(address, value);
178
+ }
179
+ return;
106
180
  }
107
181
  if (address < RAM_BASE) {
182
+ if (this.videoBit6Gate) value = (value & 0xbf) | (value & 0xa0 ? 0 : 0x40);
108
183
  this.videoRam[address - KEYBOARD_LIMIT] = value;
109
184
  this.videoDirty = true;
110
185
  return;
111
186
  }
112
- this.ram[address - RAM_BASE] = value;
187
+ if (address < this.ramLimit) {
188
+ this.ram[address - RAM_BASE] = value;
189
+ }
113
190
  }
114
191
 
115
192
  /**
@@ -198,10 +275,16 @@ export class MemorySystem {
198
275
  */
199
276
  getMemoryView() {
200
277
  const view = new Uint8Array(0x10000);
201
- view.set(this.rom.subarray(0, ROM_LIMIT), 0);
202
- // Keyboard region reads as idle (0x00) in the debug view
278
+ // Unmapped addresses read 0xFF; the keyboard region reads as idle (0x00) in the debug view
279
+ view.fill(0xff, this.romLimit, KEYBOARD_BASE);
280
+ view.fill(0xff, this.ramLimit);
281
+ view.set(this.rom.subarray(0, this.romLimit), 0);
203
282
  view.set(this.videoRam, KEYBOARD_LIMIT);
204
283
  view.set(this.ram, RAM_BASE);
284
+ // The Model I's Expansion Interface, peeked, as m.mem.read shows it
285
+ if (this.expansion !== null) {
286
+ for (let a = EXPANSION_BASE; a < EXPANSION_LIMIT; a++) view[a] = this.expansion.peekExpansion(a);
287
+ }
205
288
  return view;
206
289
  }
207
290
  }
@@ -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
+ }
@@ -1,14 +1,21 @@
1
1
  /**
2
- * Known Model III ROM images by SHA-256 (lowercase hex), so the Machine can report which ROM it
3
- * was given (spec §5: the core validates by length and a known-hash table). Hashes are facts
4
- * about the images; the images themselves are never committed or bundled.
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.
5
7
  *
6
- * Source for both: github.com/kiwisincebirth/TRS-80-ROMS, folder test-roms/, hashed locally
7
- * 2026-10-17. `Model3-RevC-2EF8.bin` is the stock Model III Rev C ROM (14,336 bytes) and the
8
- * image the repository's fugloy.mcp.json names as roms.model3; `Model3-RevC-FreHD.bin` is the
9
- * same ROM patched to boot a FreHD hard-disk emulator.
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.
10
15
  */
11
16
  export const ROM_HASHES = Object.freeze({
17
+ '3cd3824d2ac2743750d2179951ed812794302b885ccb4f98aa2579b352c4b00e': 'model1-level2-1.2',
18
+ '5515d95d7c19058400a2e91c6930bb557035db5ce093bd96fd017f795c346776': 'model1-level2-1.3',
12
19
  beeb3741175a8b8706cc384d5b2534ebf32e383662864054e5a0ce4d861a43bb: 'model3-rev-c-2ef8',
13
20
  ad6de30c6aece2d6a2243ee90a2959295d5c46b82650b27f46f1ecf8b34b2361: 'model3-rev-c-frehd',
14
21
  });
@@ -1,10 +1,14 @@
1
1
  /**
2
- * TRS-80 Model III System
2
+ * TRS-80 System, Model III or Model I
3
3
  *
4
4
  * Owns and wires the machine: Z80 CPU, memory map, I/O ports, and the
5
- * keyboard matrix. Runs the CPU in T-state-budgeted slices at the real
6
- * 2.02752 MHz clock and raises the 30 Hz RTC heartbeat interrupt the
7
- * ROM's clock/cursor services depend on.
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.
8
12
  *
9
13
  * Rendering is the caller's concern: read `memory.videoRam` (or
10
14
  * `screenText()`) and consult `memory.videoDirty`. Keyboard input goes
@@ -13,17 +17,37 @@
13
17
 
14
18
  import { Z80CPU } from "../cpu/z80cpu.js";
15
19
  import { MemorySystem } from "./memory.js";
16
- import { IOSystem, MODE_32COL_BIT } from "./io.js";
20
+ import { IOSystem, MODE_32COL_BIT, M1_MODESEL_BIT } from "./io.js";
21
+ import { TIMER_BIT } from "../devices/interrupt-latch.js";
17
22
  import { KeyboardMatrix } from "../devices/keyboard.js";
23
+ import { MODEL_III, modelOf } from "./models.js";
18
24
 
19
- export const CPU_CLOCK_HZ = 2027520;
20
- export const RTC_HZ = 30;
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;
21
28
  export const RTC_INTERVAL_TSTATES = CPU_CLOCK_HZ / RTC_HZ; // 67,584
22
29
 
23
30
  export class TRS80System {
24
- constructor({ romData, log }) {
25
- this.memory = new MemorySystem();
26
- this.io = new IOSystem();
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;
27
51
  this.keyboard = new KeyboardMatrix();
28
52
  this.cpu = new Z80CPU();
29
53
  if (log !== undefined) {
@@ -47,9 +71,22 @@ export class TRS80System {
47
71
  // Cassette-out sound transitions are stamped in CPU T-states
48
72
  this.io.getCycles = () => this.cpu.cycles;
49
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
+
50
87
  this.memory.loadROM(romData);
51
88
 
52
- this.nextRtcAt = RTC_INTERVAL_TSTATES;
89
+ this.nextRtcAt = this.rtcInterval;
53
90
  this.reset();
54
91
  }
55
92
 
@@ -63,7 +100,7 @@ export class TRS80System {
63
100
  this.io.reset();
64
101
  this.keyboard.reset();
65
102
  this.memory.clearVideoRAM();
66
- this.nextRtcAt = this.cpu.cycles + RTC_INTERVAL_TSTATES;
103
+ this.nextRtcAt = this.cpu.cycles + this.rtcInterval;
67
104
  this._nmiWasPending = false;
68
105
  }
69
106
 
@@ -98,44 +135,64 @@ export class TRS80System {
98
135
  stepInstruction(beforeExecute) {
99
136
  const start = this.cpu.cycles;
100
137
  if (this.cpu.cycles >= this.nextRtcAt) {
101
- this.io.raiseRTC();
102
- this.nextRtcAt += RTC_INTERVAL_TSTATES;
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;
103
141
  }
104
142
  if (this.io.pendingInterrupt()) {
105
143
  this.cpu.interrupt();
106
144
  }
107
- // FDC INTRQ -> NMI, edge-triggered so one assertion fires one NMI
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.
108
149
  const nmiNow = this.io.nmiPending();
109
150
  if (nmiNow && !this._nmiWasPending) {
110
- this.cpu.nmi();
151
+ if (this.cpu.nmi()) this._nmiWasPending = true;
152
+ } else {
153
+ this._nmiWasPending = nmiNow;
111
154
  }
112
- this._nmiWasPending = nmiNow;
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);
113
162
 
114
163
  // beforeExecute returning true has executed the boundary's instruction itself (the
115
164
  // Machine's ROM keyboard-scan hook stands in for a RET), so the CPU does not run one.
116
- // Returning 'stop' runs nothing and the disk controller does not tick: like the normal path,
117
- // it never ticks for an interrupt response's T-states. The return value is those T-states.
118
- const before = this.cpu.cycles;
119
- if (beforeExecute !== undefined) {
120
- const r = beforeExecute();
121
- if (r === 'stop') {
122
- return before - start;
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();
123
176
  }
124
- if (r !== true) this.cpu.executeInstruction();
125
- } else {
126
- this.cpu.executeInstruction();
177
+ } catch (e) {
178
+ this._fdcAhead = 0;
179
+ throw e;
180
+ } finally {
181
+ this._executing = false;
127
182
  }
128
- this.io.fdc.tick(this.cpu.cycles - before);
183
+ this.io.fdc.tick(this.cpu.cycles - before - this._fdcAhead);
184
+ this._fdcAhead = 0;
129
185
  return this.cpu.cycles - start;
130
186
  }
131
187
 
132
188
  /**
133
- * True when the machine is in 32-character mode (mode register bit 2,
134
- * set by the ROM for CHR$(23), cleared by CLS). The ROM lays 32-column
135
- * text at even video addresses; hardware shows them double-wide.
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.
136
193
  */
137
194
  get columns32() {
138
- return (this.io.modeRegister & MODE_32COL_BIT) !== 0;
195
+ return (this.io.modeRegister & this._mode32Bit) !== 0;
139
196
  }
140
197
 
141
198
  /** Mount a .dsk image (DiskImage) in drive 0-3. */
@@ -176,7 +233,7 @@ export class TRS80System {
176
233
 
177
234
  /** Convenience for tests: run N emulated seconds. */
178
235
  runSeconds(seconds) {
179
- return this.runTStates(Math.round(seconds * CPU_CLOCK_HZ));
236
+ return this.runTStates(Math.round(seconds * this.clockHz));
180
237
  }
181
238
 
182
239
  /**