@fugloy/emu 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The Model I Expansion Interface's interrupt latch at $37E0: one object, owned by the Model I's
3
+ * I/O decode (`IOSystem.latch`, null on the Model III and on a Model I built with
4
+ * `expansion: false`), that every interrupting device of the Expansion Interface raises its bit in.
5
+ *
6
+ * - Bit 7 is the real-time clock: the Expansion Interface's 40 Hz pulse (a "25-millisecond pulse
7
+ * on the interrupt line", "this 40-times-per-second pulse": Kitsz, The Custom TRS-80 and Other
8
+ * Mysteries, p. 136; MAME's trs80 driver, `IRQ_M1_RTC` 0x80 and its 40 Hz periodic interrupt).
9
+ * TRS80System raises it on schedule (`raise(TIMER_BIT)`).
10
+ * - Bit 6 is the disk controller's INTRQ (MAME, `IRQ_M1_FDC` 0x40). The WD1771 (emu phase 6,
11
+ * Task 3) drives it with `raise(DISK_BIT)` and `clear(DISK_BIT)` as its INTRQ rises and falls;
12
+ * reading the controller's status at $37EC is what drops it.
13
+ *
14
+ * Any bit set holds the CPU's INT line low; the Z80 takes it in IM 1 (or IM 0 with $FF on the bus)
15
+ * at $0038 whenever its interrupts are enabled. There is no mask: every source interrupts.
16
+ *
17
+ * Reading $37E0 (and its mirrors $37E1-$37E3, as MAME maps them; the mirrors are unverified) returns
18
+ * the latch and clears the clock bit: "reading 37EC (disk controller status) followed by reading
19
+ * 37E0 (real-time clock) resets a flip-flop used to signal input from disk controller and real-time
20
+ * clock, respectively. This must be done each time or the interrupt will continue to be 'on'"
21
+ * (Kitsz, p. 137). The disk bit is left for the status read to clear. Bits 0-5 read 0
22
+ * (unverified; MAME and xtrs return only the sources).
23
+ *
24
+ * The Level II ROM never reads it: its interrupt vector at $4012 is `EI / RET`
25
+ * (MDL1LEV2.Z80, INITR at $06D2), and Level II BASIC runs with interrupts disabled (DI at $0000 and
26
+ * no EI outside that vector), so the clock bit simply stays set until a DOS or a program enables
27
+ * interrupts with a handler that reads $37E0.
28
+ */
29
+
30
+ /** The real-time clock's bit. */
31
+ export const TIMER_BIT = 0x80;
32
+ /** The disk controller's INTRQ bit. */
33
+ export const DISK_BIT = 0x40;
34
+
35
+ export class InterruptLatch {
36
+ /** @type {number} The pending sources (TIMER_BIT, DISK_BIT). */
37
+ bits = 0;
38
+
39
+ /**
40
+ * Set source bits: the INT line goes (or stays) low.
41
+ * @param {number} mask
42
+ */
43
+ raise(mask) {
44
+ this.bits |= mask & 0xff;
45
+ }
46
+
47
+ /**
48
+ * Clear source bits.
49
+ * @param {number} mask
50
+ */
51
+ clear(mask) {
52
+ this.bits &= ~mask & 0xff;
53
+ }
54
+
55
+ /** True while any source holds the INT line. */
56
+ get pending() {
57
+ return this.bits !== 0;
58
+ }
59
+
60
+ /** A CPU read of $37E0: the latch, after which the clock bit is cleared. */
61
+ read() {
62
+ const value = this.bits;
63
+ this.bits &= ~TIMER_BIT;
64
+ return value;
65
+ }
66
+
67
+ /** The latch as a read would return it, clearing nothing (for the debugger and `m.mem.read`). */
68
+ peek() {
69
+ return this.bits;
70
+ }
71
+
72
+ /** Power-on: nothing pending. */
73
+ reset() {
74
+ this.bits = 0;
75
+ }
76
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The spec §3 `m.keyboard`: a cycle-timed key queue over the KeyboardMatrix, and the feed buffer
3
+ * the ROM keyboard-scan hook (spec §6) reads.
4
+ *
5
+ * Matrix mode presses and releases matrix keys at T-state timestamps, as a typist would; any
6
+ * program sees them, the ROM and games that scan $3800 directly alike. Hook mode hands characters
7
+ * to the ROM's own scan entries ($002B, $0049) without touching the matrix, so it is fast and
8
+ * exact but reaches only code that calls the ROM. The Machine drains the queue at instruction
9
+ * boundaries and runs the hook there.
10
+ */
11
+ import { CPU_CLOCK_HZ } from '../machine/trs80-system.js';
12
+
13
+ /** Key names → the KeyboardMatrix's browser-key names. */
14
+ const NAMES = {
15
+ ENTER: 'Enter',
16
+ CLEAR: 'Clear',
17
+ BREAK: 'Escape',
18
+ UP: 'ArrowUp',
19
+ DOWN: 'ArrowDown',
20
+ LEFT: 'ArrowLeft',
21
+ RIGHT: 'ArrowRight',
22
+ SHIFT: 'Shift',
23
+ SPACE: ' ',
24
+ };
25
+
26
+ /**
27
+ * The KeyboardMatrix key for a character or key name. The matrix maps browser keys, where an
28
+ * uppercase letter is physically shifted; on the Model III an unshifted letter key types the
29
+ * capital, so 'A' presses the bare A key and 'a' presses SHIFT+A.
30
+ * @param {string} key
31
+ * @returns {string}
32
+ */
33
+ function matrixKey(key) {
34
+ if (Object.hasOwn(NAMES, key)) return NAMES[key];
35
+ if (key === '\n') return 'Enter';
36
+ if (/^[A-Z]$/.test(key)) return key.toLowerCase();
37
+ if (/^[a-z]$/.test(key)) return key.toUpperCase();
38
+ return key;
39
+ }
40
+
41
+ /**
42
+ * The byte the ROM's keyboard routine returns for a fed character: its ASCII code, with '\n'
43
+ * as ENTER ($0D).
44
+ * @param {string} ch
45
+ * @returns {number}
46
+ */
47
+ function romCode(ch) {
48
+ if (ch === '\n') return 0x0d;
49
+ const code = ch.codePointAt(0) ?? 0;
50
+ if (ch.length !== 1 || code < 0x01 || code > 0x7f) throw new Error(`feed: ${JSON.stringify(ch)} is not a character the ROM keyboard routine returns (ASCII $01-$7F, or '\\n' for ENTER)`);
51
+ return code;
52
+ }
53
+
54
+ /** Sign, zero, the undocumented bits 5 and 3, and parity, as the Z80 sets them for a logical op on `v`. */
55
+ function logicFlags(v) {
56
+ let parity = v;
57
+ parity ^= parity >> 4;
58
+ parity ^= parity >> 2;
59
+ parity ^= parity >> 1;
60
+ return (v & 0xa8) | (v === 0 ? 0x40 : 0) | ((parity & 1) === 0 ? 0x04 : 0);
61
+ }
62
+
63
+ /** F after `CP 01H` with A = `a` (the last flag-setting instruction of the $002B path, at $3100). */
64
+ function cp01Flags(a) {
65
+ const r = (a - 1) & 0xff;
66
+ return (r & 0x80) | (r === 0 ? 0x40 : 0) | ((a & 0x0f) < 1 ? 0x10 : 0) | ((a ^ 1) & (a ^ r) & 0x80 ? 0x04 : 0) | 0x02 | (a < 1 ? 0x01 : 0);
67
+ }
68
+
69
+ /** The KDCB, the keyboard device control block at $4015: $002B loads DE with it and returns it there. */
70
+ const KDCB = 0x4015;
71
+
72
+ /**
73
+ * Return from the ROM keyboard routine at `pc` ($002B or $0049) as the Model III ROM does with
74
+ * `code` in A, verified against the ROM listing (spec §6, "Keyboard-scan hook"): DE = $4015 (the
75
+ * KDCB), BC, HL, IX, IY and the alternate set unchanged, F as the routine's last flag-setting
76
+ * instruction leaves it (`CP 01H` at $3100 for $002B, `OR A` at $004C for $0049), and the return
77
+ * address popped.
78
+ *
79
+ * The Model I Level II ROM keeps the same contract (MDL1LEV2.Z80, kiwisincebirth/TRS-80-ROMS at
80
+ * 736bcba): `_KBD` at $002B loads DE with the KDCB ($4015) and goes through `_GET` ($0013) and
81
+ * `CIO` ($03C2), which saves BC, HL, IX, and that DE and restores them at `CIORTN` ($03DD), to the
82
+ * keyboard driver `KBDDRV` ($03E3, the KDCB's driver address at $4016), whose path for a key ends
83
+ * with `CP $01` at $0453 (the BREAK test) and `RET NZ`, or for BREAK `RST 28H` (a RET under
84
+ * Level II, $400C) and `RET`; `_KEY` at $0049 is `CALL _KBD / OR A / RET NZ / JR _KEY`. So the
85
+ * stand-in is the same on both models (phase 6 plan, Ruling 6). It also does what the CPU does at
86
+ * the end of any instruction: clears `afterPrefix` and counts a pending EI down. It costs what a
87
+ * RET costs, 10 T-states and one refresh-counter step; the routine's RAM side effects (its
88
+ * key-rollover buffer at $4036, the repeat counters) are not made.
89
+ * @param {import('../cpu/z80cpu.js').Z80CPU} cpu
90
+ * @param {number} pc
91
+ * @param {number} code
92
+ */
93
+ export function romKeyReturn(cpu, pc, code) {
94
+ const r = cpu.registers;
95
+ cpu.afterPrefix = false;
96
+ r.A = code;
97
+ r.F = pc === 0x0049 ? logicFlags(code) : cp01Flags(code);
98
+ r.D = KDCB >> 8;
99
+ r.E = KDCB & 0xff;
100
+ const sp = r.SP;
101
+ r.PC = cpu.readMemory(sp) | (cpu.readMemory((sp + 1) & 0xffff) << 8);
102
+ r.SP = (sp + 2) & 0xffff;
103
+ cpu.incrementR();
104
+ // The end of any instruction (Z80CPU.executeInstruction): a pending EI counts down and takes effect.
105
+ if (cpu.pendingEI > 0) {
106
+ cpu.pendingEI--;
107
+ if (cpu.pendingEI === 0) {
108
+ cpu.IFF1 = true;
109
+ cpu.IFF2 = true;
110
+ }
111
+ }
112
+ cpu.lastOpCycles = 10;
113
+ cpu.cycles += 10;
114
+ }
115
+
116
+ /**
117
+ * @typedef {{ at: number, down: boolean, key: string, code: string }} KeyEvent
118
+ */
119
+
120
+ export class KeyQueue {
121
+ /** @type {import('./keyboard.js').KeyboardMatrix} */
122
+ #matrix;
123
+ /** @type {() => number} */
124
+ #now;
125
+ /** @type {number} */
126
+ #clockHz;
127
+ /** @type {KeyEvent[]} Matrix events in time order. */
128
+ #events = [];
129
+ /** @type {number} Index of the next undelivered matrix event. */
130
+ #next = 0;
131
+ /** @type {number[]} ROM codes waiting for the keyboard-scan hook. */
132
+ #feed = [];
133
+ #feedNext = 0;
134
+ /** @type {number} The T-state the queued typing ends at; new typing starts no earlier. */
135
+ #end = 0;
136
+ #seq = 0;
137
+ /** T-state of the next matrix event, Infinity when none: the Machine's cheap per-instruction check. */
138
+ nextAt = Infinity;
139
+
140
+ /**
141
+ * @param {import('./keyboard.js').KeyboardMatrix} matrix
142
+ * @param {() => number} now the current T-state
143
+ */
144
+ /**
145
+ * @param {import('./keyboard.js').KeyboardMatrix} matrix
146
+ * @param {() => number} now the CPU's T-state count
147
+ * @param {number} [clockHz] the CPU clock `type`'s cps counts in (the Model III's by default)
148
+ */
149
+ constructor(matrix, now, clockHz = CPU_CLOCK_HZ) {
150
+ this.#matrix = matrix;
151
+ this.#now = now;
152
+ this.#clockHz = clockHz;
153
+ }
154
+
155
+ /**
156
+ * Press a key now and hold it until `release`.
157
+ * @param {string} key a character ('A', '1', ' ') or a name: ENTER, CLEAR, BREAK, UP, DOWN, LEFT, RIGHT, SHIFT, SPACE
158
+ */
159
+ press(key) {
160
+ this.#matrix.keyDown(this.#mapped(key, 'press'), `press:${key}`);
161
+ }
162
+
163
+ /**
164
+ * Release a key `press` holds.
165
+ * @param {string} key
166
+ */
167
+ release(key) {
168
+ this.#matrix.keyUp(`press:${key}`);
169
+ }
170
+
171
+ /**
172
+ * Queue typing. In matrix mode each character is pressed for half of a 1/cps-second window and
173
+ * released for the other half, so a repeated letter registers twice; typing starts now, or when
174
+ * the typing already queued ends. In hook mode it is `feed`.
175
+ *
176
+ * Pick `cps` for the program. The default 10 cps holds each key 50 ms of emulated time, long
177
+ * enough for the ROM's keyboard driver, which debounces. A program that reads the keyboard
178
+ * slowly, or only once a frame or less often, can miss a short tap: Rear Guard needs ENTER held
179
+ * about half a second (`{ cps: 1 }`). A high `cps` shortens the hold and can drop keys, and
180
+ * typing at the ROM's line editor with no pause for the ENTER can lose characters. For
181
+ * `press` and `release`, use the same name for both: a mismatch leaves the key down.
182
+ *
183
+ * `str` is a string of characters, or an array whose every element is one key: a character or
184
+ * a key name (`['A', 'ENTER', 'UP']`), so named keys can be typed in sequence; in hook mode
185
+ * every element must be a character the ROM routine returns.
186
+ * @param {string | string[]} str '\n' is ENTER
187
+ * @param {{ cps?: number, mode?: 'matrix' | 'hook' }} [opts]
188
+ */
189
+ type(str, { cps = 10, mode = 'matrix' } = {}) {
190
+ if (mode === 'hook') return this.feed(str);
191
+ if (mode !== 'matrix') throw new Error(`type: mode must be 'matrix' or 'hook', not ${String(mode)}`);
192
+ if (!(cps > 0) || !Number.isFinite(cps)) throw new Error(`type: cps must be a positive number, not ${String(cps)}`);
193
+ const period = Math.round(this.#clockHz / cps);
194
+ const hold = Math.round(period / 2);
195
+ const keys = Array.from(str, (ch) => this.#mapped(ch, 'type'));
196
+ this.#compact();
197
+ let t = Math.max(this.#now(), this.#end);
198
+ for (const key of keys) {
199
+ const code = `queue:${this.#seq++}`;
200
+ this.#events.push({ at: t, down: true, key, code }, { at: t + hold, down: false, key, code });
201
+ t += period;
202
+ }
203
+ this.#end = t;
204
+ this.nextAt = this.#events[this.#next]?.at ?? Infinity;
205
+ }
206
+
207
+ /**
208
+ * Queue characters for the ROM keyboard-scan hook: each is returned once by the next call to
209
+ * $002B or $0049. The matrix is not touched.
210
+ * All characters are checked first, so a bad one throws and queues nothing.
211
+ * @param {string | string[]} str '\n' is ENTER; an array holds one character an element
212
+ */
213
+ feed(str) {
214
+ const codes = Array.from(str, romCode);
215
+ for (const code of codes) this.#feed.push(code);
216
+ }
217
+
218
+ /** Queued events not yet delivered: matrix presses and releases, and fed characters. */
219
+ get pending() {
220
+ return this.#events.length - this.#next + this.#feed.length - this.#feedNext;
221
+ }
222
+
223
+ /** True when a fed character is waiting for the hook. */
224
+ get hasFeed() {
225
+ return this.#feedNext < this.#feed.length;
226
+ }
227
+
228
+ /** The next fed character's ROM code, removed from the buffer. */
229
+ takeFeed() {
230
+ const code = this.#feed[this.#feedNext++];
231
+ if (this.#feedNext === this.#feed.length) {
232
+ this.#feed.length = 0;
233
+ this.#feedNext = 0;
234
+ }
235
+ return code;
236
+ }
237
+
238
+ /**
239
+ * Deliver every matrix event due at T-state `now`, in order.
240
+ * @param {number} now
241
+ */
242
+ drain(now) {
243
+ const events = this.#events;
244
+ while (this.#next < events.length && events[this.#next].at <= now) {
245
+ const e = events[this.#next++];
246
+ if (e.down) this.#matrix.keyDown(e.key, e.code);
247
+ else this.#matrix.keyUp(e.code);
248
+ }
249
+ this.nextAt = this.#next < events.length ? events[this.#next].at : Infinity;
250
+ }
251
+
252
+ /** Drop everything queued, releasing any queued key that is down; keys held with `press` stay down. */
253
+ clear() {
254
+ for (let i = this.#next; i < this.#events.length; i++) {
255
+ if (!this.#events[i].down) this.#matrix.keyUp(this.#events[i].code);
256
+ }
257
+ this.#events = [];
258
+ this.#next = 0;
259
+ this.#feed.length = 0;
260
+ this.#feedNext = 0;
261
+ this.#end = 0;
262
+ this.nextAt = Infinity;
263
+ }
264
+
265
+ #compact() {
266
+ if (this.#next > 0) {
267
+ this.#events = this.#events.slice(this.#next);
268
+ this.#next = 0;
269
+ }
270
+ }
271
+
272
+ /** @param {string} key @param {string} who */
273
+ #mapped(key, who) {
274
+ const k = matrixKey(key);
275
+ if (!this.#matrix.knows(k)) throw new Error(`${who}: no matrix key for ${JSON.stringify(key)}`);
276
+ return k;
277
+ }
278
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * TRS-80 Model III Keyboard Matrix
3
+ *
4
+ * The keyboard is an 8x8 switch matrix memory-mapped at 0x3800-0x3BFF.
5
+ * Address bits 0-7 select rows; reading returns the OR of every selected
6
+ * row's key bits (bit set = key down). The ROM scans by reading
7
+ * 0x3801, 0x3802, 0x3804 ... 0x3880.
8
+ *
9
+ * Row layout:
10
+ * row 0: @ A B C D E F G row 4: 0 1 2 3 4 5 6 7
11
+ * row 1: H I J K L M N O row 5: 8 9 : ; , - . /
12
+ * row 2: P Q R S T U V W row 6: ENT CLR BRK ↑ ↓ ← → SPC
13
+ * row 3: X Y Z row 7: SHIFT (bit 0), right SHIFT (bit 1)
14
+ *
15
+ * Browser keys are mapped by the character they produce (e.key), with a
16
+ * synthetic SHIFT for characters that live on a shifted TRS-80 key (e.g.
17
+ * `"` is SHIFT+2). Each press is remembered by its physical key (e.code)
18
+ * so keyUp releases exactly the combo keyDown pressed.
19
+ */
20
+
21
+ const SHIFT = { row: 7, bit: 0 };
22
+
23
+ function key(row, bit) {
24
+ return [{ row, bit }];
25
+ }
26
+
27
+ function shifted(row, bit) {
28
+ return [{ row, bit }, SHIFT];
29
+ }
30
+
31
+ function buildKeyMap() {
32
+ const map = new Map();
33
+
34
+ // Letters: rows 0-3, @ at row 0 bit 0, then A-Z in order.
35
+ // Model III convention: unshifted letter keys type UPPERCASE, and
36
+ // SHIFT+letter types lowercase. Browser lowercase input maps to the
37
+ // bare key; browser uppercase (physically shifted) maps to SHIFT+key,
38
+ // matching the real machine exactly.
39
+ map.set("@", key(0, 0));
40
+ for (let i = 0; i < 26; i++) {
41
+ const letter = String.fromCharCode(0x41 + i); // 'A'...
42
+ const pos = i + 1; // A is row 0 bit 1
43
+ const row = Math.floor(pos / 8);
44
+ const bit = pos % 8;
45
+ map.set(letter.toLowerCase(), key(row, bit));
46
+ map.set(letter, shifted(row, bit));
47
+ }
48
+
49
+ // Digits: 0-7 on row 4, 8-9 on row 5 bits 0-1
50
+ for (let d = 0; d <= 7; d++) {
51
+ map.set(String(d), key(4, d));
52
+ }
53
+ map.set("8", key(5, 0));
54
+ map.set("9", key(5, 1));
55
+
56
+ // Unshifted punctuation on row 5
57
+ map.set(":", key(5, 2));
58
+ map.set(";", key(5, 3));
59
+ map.set(",", key(5, 4));
60
+ map.set("-", key(5, 5));
61
+ map.set(".", key(5, 6));
62
+ map.set("/", key(5, 7));
63
+
64
+ // Shifted symbols per the Level II keyboard legends
65
+ map.set("!", shifted(4, 1));
66
+ map.set('"', shifted(4, 2));
67
+ map.set("#", shifted(4, 3));
68
+ map.set("$", shifted(4, 4));
69
+ map.set("%", shifted(4, 5));
70
+ map.set("&", shifted(4, 6));
71
+ map.set("'", shifted(4, 7));
72
+ map.set("(", shifted(5, 0));
73
+ map.set(")", shifted(5, 1));
74
+ map.set("*", shifted(5, 2));
75
+ map.set("+", shifted(5, 3));
76
+ map.set("<", shifted(5, 4));
77
+ map.set("=", shifted(5, 5));
78
+ map.set(">", shifted(5, 6));
79
+ map.set("?", shifted(5, 7));
80
+
81
+ // Control and navigation keys (row 6) and shift (row 7)
82
+ map.set("Enter", key(6, 0));
83
+ map.set("Clear", key(6, 1)); // dedicated CLEAR if the browser reports one
84
+ map.set("Home", key(6, 1)); // common stand-in for CLEAR
85
+ map.set("Escape", key(6, 2)); // BREAK
86
+ map.set("Pause", key(6, 2)); // BREAK on keyboards that have it
87
+ map.set("ArrowUp", key(6, 3));
88
+ map.set("ArrowDown", key(6, 4));
89
+ map.set("ArrowLeft", key(6, 5));
90
+ map.set("Backspace", key(6, 5)); // ← doubles as delete in Level II
91
+ map.set("ArrowRight", key(6, 6));
92
+ map.set(" ", key(6, 7));
93
+ map.set("Shift", [SHIFT]);
94
+
95
+ return map;
96
+ }
97
+
98
+ const KEY_MAP = buildKeyMap();
99
+
100
+ export class KeyboardMatrix {
101
+ constructor() {
102
+ this.rows = new Uint8Array(8);
103
+ // physical key id (e.code) -> combo currently held for it
104
+ this.pressed = new Map();
105
+ this._boundKeyDown = null;
106
+ this._boundKeyUp = null;
107
+ this._element = null;
108
+ }
109
+
110
+ /** Row-select read: OR of every row whose select bit is set. */
111
+ read(select) {
112
+ let value = 0;
113
+ let sel = select & 0xff;
114
+ for (let row = 0; row < 8; row++) {
115
+ if (sel & (1 << row)) {
116
+ value |= this.rows[row];
117
+ }
118
+ }
119
+ return value;
120
+ }
121
+
122
+ /**
123
+ * Press the combo for a browser key. Returns true if the key maps to
124
+ * the TRS-80 matrix (caller should preventDefault), false otherwise.
125
+ */
126
+ keyDown(browserKey, physicalCode) {
127
+ const combo = KEY_MAP.get(browserKey);
128
+ if (!combo) {
129
+ return false;
130
+ }
131
+ this.pressed.set(physicalCode || browserKey, combo);
132
+ this._rebuild();
133
+ return true;
134
+ }
135
+
136
+ /** True when `browserKey` maps to the TRS-80 matrix. */
137
+ knows(browserKey) {
138
+ return KEY_MAP.has(browserKey);
139
+ }
140
+
141
+ /** Release whatever combo the physical key pressed. */
142
+ keyUp(physicalCode) {
143
+ if (!this.pressed.delete(physicalCode)) {
144
+ return false;
145
+ }
146
+ this._rebuild();
147
+ return true;
148
+ }
149
+
150
+ /** Directly press a matrix position (used by tests and the system). */
151
+ pressKey(row, bit) {
152
+ this.pressed.set(`matrix:${row}:${bit}`, key(row, bit));
153
+ this._rebuild();
154
+ }
155
+
156
+ releaseKey(row, bit) {
157
+ this.pressed.delete(`matrix:${row}:${bit}`);
158
+ this._rebuild();
159
+ }
160
+
161
+ reset() {
162
+ this.pressed.clear();
163
+ this.rows.fill(0);
164
+ }
165
+
166
+ // The matrix is always derived from the full set of held combos, so
167
+ // overlapping synthetic shifts can't get out of sync on release.
168
+ _rebuild() {
169
+ this.rows.fill(0);
170
+ for (const combo of this.pressed.values()) {
171
+ for (const { row, bit } of combo) {
172
+ this.rows[row] |= 1 << bit;
173
+ }
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * TRS-80 Model III line printer (ports 0xF8-0xFB)
3
+ *
4
+ * The parallel printer interface answers status reads and takes data
5
+ * writes on port 0xF8; all four addresses 0xF8-0xFB decode to it.
6
+ * Status 0x30 is "selected, ready" — busy (bit 7) and out-of-paper
7
+ * (bit 6) clear, unit-select (bit 5) and the held-high line (bit 4)
8
+ * set — which is what the ROM's LPRINT driver polls for before every
9
+ * byte. This printer is never busy and never out of paper: bytes land
10
+ * on an in-memory printout the UI offers as a .txt download.
11
+ */
12
+
13
+ const STATUS_READY = 0x30;
14
+
15
+ export class Printer {
16
+ constructor() {
17
+ this.bytes = [];
18
+ }
19
+
20
+ status() {
21
+ return STATUS_READY;
22
+ }
23
+
24
+ write(byte) {
25
+ this.bytes.push(byte & 0xff);
26
+ }
27
+
28
+ get length() {
29
+ return this.bytes.length;
30
+ }
31
+
32
+ /** The printout as text — the TRS-80 ends lines with 0x0D. */
33
+ text() {
34
+ let out = "";
35
+ for (const b of this.bytes) {
36
+ out += b === 0x0d ? "\n" : String.fromCharCode(b);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ clear() {
42
+ this.bytes = [];
43
+ }
44
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The Model III's only sound source is the cassette output on port $FF. This device keeps every
3
+ * write to that port, raw and stamped with the CPU's T-state count, in a fixed ring of 65,536
4
+ * events (typed arrays, no allocation per write). The newest events are kept; `overflowed`
5
+ * counts the ones lost to wrap-around. It is not part of serializeState.
6
+ */
7
+
8
+ const SIZE = 65536;
9
+
10
+ export class Sound {
11
+ #cycles = new Float64Array(SIZE);
12
+ #values = new Uint8Array(SIZE);
13
+ #head = 0; // next slot to write
14
+ #count = 0; // events held, at most SIZE
15
+ /** @type {number} Events lost to wrap-around since the last clear. */
16
+ overflowed = 0;
17
+
18
+ /**
19
+ * Record one port $FF write. Called by the I/O system.
20
+ * @param {number} cycle T-states at the write.
21
+ * @param {number} value The raw byte written.
22
+ */
23
+ record(cycle, value) {
24
+ this.#cycles[this.#head] = cycle;
25
+ this.#values[this.#head] = value;
26
+ this.#head = (this.#head + 1) & (SIZE - 1);
27
+ if (this.#count < SIZE) this.#count++;
28
+ else this.overflowed++;
29
+ }
30
+
31
+ /**
32
+ * Every port $FF write after a cycle, oldest first.
33
+ * @param {number} [sinceCycle] Only events with `cycle > sinceCycle`; the default -1 returns everything, including a write at cycle 0.
34
+ * @returns {{ cycle: number, value: number }[]}
35
+ */
36
+ events(sinceCycle = -1) {
37
+ const out = [];
38
+ const start = (this.#head - this.#count + SIZE) & (SIZE - 1);
39
+ for (let k = 0; k < this.#count; k++) {
40
+ const i = (start + k) & (SIZE - 1);
41
+ const cycle = this.#cycles[i];
42
+ if (cycle > sinceCycle) out.push({ cycle, value: this.#values[i] });
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Empty the ring and reset `overflowed`. */
48
+ clear() {
49
+ this.#head = 0;
50
+ this.#count = 0;
51
+ this.overflowed = 0;
52
+ }
53
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The video as a text readout: 16 lines over video RAM at $3C00-$3FFF, 64 columns, or 32 in
3
+ * 32-column mode (on the Model III, port $EC bit 2; on the Model I, port $FF bit 3, MODESEL), where
4
+ * the hardware shows the even-address bytes double-wide. It reads the live video RAM; it draws
5
+ * nothing. The Model I's video RAM sits at the same addresses (models.js); the stock machine has no
6
+ * bit 6 there, so a byte reads back, and shows, with bit 6 made from bits 5 and 7 (memory.js, which
7
+ * cites the Technical Reference Handbook), unless the lowercase modification is fitted.
8
+ */
9
+
10
+ import { fromBase64 } from '../debug/state.js';
11
+
12
+ export class Video {
13
+ #system;
14
+
15
+ /** @param {import('../machine/trs80-system.js').TRS80System} system */
16
+ constructor(system) {
17
+ this.#system = system;
18
+ }
19
+
20
+ /** @returns {Uint8Array} A live view of $3C00-$3FFF (1024 bytes). */
21
+ get raw() {
22
+ return this.#system.memory.videoRam;
23
+ }
24
+
25
+ /** @returns {64 | 32} */
26
+ get columns() {
27
+ return this.#system.columns32 ? 32 : 64;
28
+ }
29
+
30
+ /**
31
+ * The screen as 16 lines of text. Bytes below $20 show as +$40 (the Model III character
32
+ * generator folds them to $40-$5F), and graphics and special bytes ($80-$FF) show as '.'.
33
+ * @returns {string[]}
34
+ */
35
+ text() {
36
+ const ram = this.raw;
37
+ const step = this.#system.columns32 ? 2 : 1;
38
+ const cols = 64 / step;
39
+ const rows = [];
40
+ for (let row = 0; row < 16; row++) {
41
+ let line = '';
42
+ for (let col = 0; col < cols; col++) {
43
+ let byte = ram[row * 64 + col * step];
44
+ if (byte < 0x20) byte += 0x40;
45
+ line += byte >= 0x20 && byte < 0x80 ? String.fromCharCode(byte) : '.';
46
+ }
47
+ rows.push(line);
48
+ }
49
+ return rows;
50
+ }
51
+
52
+ /**
53
+ * The cells of video RAM that differ from a snapshot's (`m.debug.snapshot()`), row-major, as
54
+ * byte values over the 64-column RAM layout (32-column mode shows only even columns).
55
+ * @param {any} snap
56
+ * @returns {{ row: number, col: number, was: number, now: number }[]}
57
+ */
58
+ diff(snap) {
59
+ const text = snap?.memory?.videoRam;
60
+ if (typeof text !== 'string') throw new Error('video.diff needs a snapshot from m.debug.snapshot()');
61
+ const was = fromBase64(text);
62
+ const now = this.raw;
63
+ const out = [];
64
+ for (let i = 0; i < now.length; i++) {
65
+ if (was[i] !== now[i]) out.push({ row: i >> 6, col: i & 63, was: was[i], now: now[i] });
66
+ }
67
+ return out;
68
+ }
69
+ }