@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,333 @@
1
+ /**
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.
6
+ */
7
+ import { TRS80System } from './trs80-system.js';
8
+ import { ROM_HASHES } from './rom-hashes.js';
9
+ import { modelOf, ramProblem, romModelProblem } from './models.js';
10
+ import { sha256Hex } from './sha256.js';
11
+ import { Video } from '../devices/video.js';
12
+ import { Sound } from '../devices/sound.js';
13
+ import { loadBas } from '../loaders/bas.js';
14
+ import { cassetteDeck, diskDrives, loadCas, loadCmd, loadDsk } from '../loaders/load.js';
15
+ import { Debugger } from '../debug/debugger.js';
16
+ import { KeyQueue, romKeyReturn } from '../devices/key-queue.js';
17
+
18
+ /**
19
+ * @typedef {{ a: number, f: number, b: number, c: number, d: number, e: number, h: number, l: number,
20
+ * a2: number, f2: number, b2: number, c2: number, d2: number, e2: number, h2: number, l2: number,
21
+ * ix: number, iy: number, sp: number, pc: number, i: number, r: number,
22
+ * iff1: boolean, iff2: boolean, im: number }} Regs
23
+ * @typedef {{ s: boolean, z: boolean, h: boolean, pv: boolean, n: boolean, c: boolean }} Flags
24
+ * @typedef {'halt' | ((m: Machine) => boolean)} Until
25
+ */
26
+
27
+ export class Machine {
28
+ /** @type {TRS80System} The wrapped system: an escape hatch to the devices, outside the stable API. */
29
+ system;
30
+ /** @type {{ bytes: number, sha256: string, recognized: string | null }} */
31
+ rom;
32
+ /** @type {{ readonly regs: Regs, readonly flags: Flags }} */
33
+ cpu;
34
+ /** @type {{ read(addr: number, len: number): Uint8Array, write(addr: number, bytes: ArrayLike<number>): void, readonly ram: Uint8Array }} */
35
+ mem;
36
+ /** @type {{ in(port: number): number, out(port: number, value: number): void }} */
37
+ io;
38
+ /** @type {Video} The text readout of video RAM. */
39
+ video;
40
+ /** @type {Sound} Every port $FF write, raw and stamped in T-states. */
41
+ sound;
42
+ /** @type {KeyQueue} Key presses now, cycle-timed typing through the matrix, and the ROM keyboard-scan hook's feed. */
43
+ keyboard;
44
+ /**
45
+ * @type {{ bas(bytes: Uint8Array): ReturnType<typeof loadBas>, cmd(bytes: Uint8Array): ReturnType<typeof loadCmd>,
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> }}
48
+ * Program loaders; each returns a manifest of where the bytes land and why.
49
+ */
50
+ load;
51
+ /** @type {ReturnType<typeof cassetteDeck>} The cassette deck: insert a .CAS at a baud, rewind, read the motor relay. */
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;
55
+ /** @type {Debugger} Breakpoints, watchpoints, hooks, coverage, the instruction trace, snapshots, labels, and the label file (spec §7, §8). */
56
+ debug;
57
+
58
+ /** True inside `run`, false inside `step`, which ignores breakpoints. */
59
+ #checkBreaks = false;
60
+ /** True until a run's first boundary has passed; that boundary is never checked, so a run resumes past a break. */
61
+ #firstBoundary = false;
62
+ /** @type {number | null} The PC where the last run stopped for a breakpoint: the next run's first boundary skips the check there only. */
63
+ #resumePc = null;
64
+ /** True while `step` or `run` executes, so a callback cannot re-enter either. */
65
+ #busy = false;
66
+ /** Set by the hook when a breakpoint held the boundary, read by `run`. */
67
+ #stopped = false;
68
+
69
+ /**
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
74
+ */
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)}.`);
84
+ }
85
+ const level2 = roms?.level2;
86
+ if (!ArrayBuffer.isView(level2) || level2.BYTES_PER_ELEMENT !== 1) throw new Error('Machine needs roms.level2, the ROM image as a Uint8Array');
87
+
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.
90
+ const sha256 = sha256Hex(level2);
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 };
96
+
97
+ const sys = this.system;
98
+ const cpu = sys.cpu;
99
+ this.cpu = {
100
+ get regs() {
101
+ const r = cpu.registers;
102
+ return {
103
+ a: r.A, f: r.F, b: r.B, c: r.C, d: r.D, e: r.E, h: r.H, l: r.L,
104
+ a2: r.A_, f2: r.F_, b2: r.B_, c2: r.C_, d2: r.D_, e2: r.E_, h2: r.H_, l2: r.L_,
105
+ ix: cpu.IX, iy: cpu.IY, sp: r.SP, pc: r.PC, i: r.I, r: r.R,
106
+ iff1: cpu.IFF1, iff2: cpu.IFF2, im: cpu.interruptMode,
107
+ };
108
+ },
109
+ get flags() {
110
+ const f = cpu.registers.F;
111
+ 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 };
112
+ },
113
+ };
114
+ this.mem = {
115
+ read(addr, len) {
116
+ const out = new Uint8Array(len);
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);
119
+ return out;
120
+ },
121
+ write(addr, bytes) {
122
+ for (let k = 0; k < bytes.length; k++) sys.memory.writeByte((addr + k) & 0xffff, bytes[k]);
123
+ },
124
+ get ram() {
125
+ return sys.memory.ram;
126
+ },
127
+ };
128
+ this.video = new Video(sys);
129
+ this.sound = new Sound();
130
+ sys.io.onSoundWrite = (cycle, value) => this.sound.record(cycle, value);
131
+ sys.io.onSoundReset = () => this.sound.clear();
132
+ this.keyboard = new KeyQueue(sys.keyboard, () => sys.cpu.cycles, sys.clockHz);
133
+ this.debug = new Debugger(this);
134
+ // Each load records its manifest and the bytes' hash for the label file (the last one wins).
135
+ const record = (/** @type {any} */ manifest, /** @type {Uint8Array} */ bytes) => {
136
+ this.debug.recordLoad(manifest, bytes);
137
+ return manifest;
138
+ };
139
+ this.load = {
140
+ bas: (bytes) => record(loadBas(this, bytes), bytes),
141
+ cmd: (bytes) => record(loadCmd(this, bytes), bytes),
142
+ cas: (bytes, opts) => record(loadCas(this, bytes, opts), bytes),
143
+ dsk: (bytes, opts) => record(loadDsk(this, bytes, opts), bytes),
144
+ };
145
+ this.cassette = cassetteDeck(this);
146
+ this.fdc = diskDrives(this);
147
+ this.io = {
148
+ in: (port) => sys.io.readPort(port),
149
+ out: (port, value) => sys.io.writePort(port, value),
150
+ };
151
+ }
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
+
167
+ /** Total T-states since the last reset. */
168
+ get cycles() {
169
+ return this.system.cpu.cycles;
170
+ }
171
+
172
+ /** Hardware reset: CPU to $0000, cycle count to 0, RAM kept (TRS80System.reset). */
173
+ reset() {
174
+ this.system.reset();
175
+ this.#resumePc = null;
176
+ this.sound.clear();
177
+ this.keyboard.clear();
178
+ }
179
+
180
+ /**
181
+ * Internal: a restore starts a fresh timeline, so the PC where the last run stopped for a
182
+ * breakpoint is forgotten, and the key queue and sound ring (whose cycle stamps belong to the
183
+ * old timeline) are cleared.
184
+ */
185
+ newTimeline() {
186
+ this.#resumePc = null;
187
+ this.sound.clear();
188
+ this.keyboard.clear();
189
+ }
190
+
191
+ /**
192
+ * The internal per-instruction hook, run at every instruction boundary (after interrupt
193
+ * delivery, before the instruction executes) by both `step` and `run`: deliver the matrix key
194
+ * events now due, then, with a fed character waiting and the PC at the ROM's keyboard scan
195
+ * ($002B) or wait-for-key ($0049), return from that routine with the character as the ROM
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.
200
+ * Between draining and the keyboard hook, a breakpoint hit (only while `debug.active`, and
201
+ * never in `step`) stops the boundary: nothing executes. Last, with memory accesses watched or
202
+ * hooked, the debugger is told an instruction starts at PC, so its fetches are not taken for
203
+ * reads and its accesses carry its PC; the keyboard hook's stand-in and a halted CPU's idle
204
+ * start none (the idle reads nothing; the stand-in's stack reads carry the CPU's PC).
205
+ * @returns {boolean | 'stop'} true when the hook stood in for the instruction, 'stop' when a breakpoint holds the boundary
206
+ */
207
+ #beforeInstruction() {
208
+ const cpu = this.system.cpu;
209
+ const kb = this.keyboard;
210
+ if (cpu.cycles >= kb.nextAt) kb.drain(cpu.cycles);
211
+ // A run's first boundary skips the check only at the PC its predecessor stopped at; a halted
212
+ // CPU idles at HALT+1 on every boundary, so it is never checked.
213
+ const pc = cpu.registers.PC;
214
+ const first = this.#firstBoundary && pc === this.#resumePc;
215
+ this.#firstBoundary = false;
216
+ this.#resumePc = null;
217
+ if (this.debug.active && this.#checkBreaks && !cpu.halted && this.debug.beforeInstruction(pc, first)) {
218
+ this.#stopped = true;
219
+ this.#resumePc = pc;
220
+ return 'stop';
221
+ }
222
+ if (cpu.halted) return false;
223
+ if (kb.hasFeed && (pc === 0x002b || pc === 0x0049)) {
224
+ const debug = this.debug;
225
+ if (debug.tracksAccess) {
226
+ debug.standIn(true);
227
+ try {
228
+ romKeyReturn(cpu, pc, kb.takeFeed());
229
+ } finally {
230
+ debug.standIn(false);
231
+ }
232
+ } else romKeyReturn(cpu, pc, kb.takeFeed());
233
+ return true;
234
+ }
235
+ if (this.debug.startsInstruction) this.debug.startInstruction(pc);
236
+ return false;
237
+ }
238
+
239
+ /** Bound once, so the hot loop passes the same function every instruction. */
240
+ #hook = () => this.#beforeInstruction();
241
+
242
+ /**
243
+ * Execute `n` whole instructions (an interrupt response counts with the instruction after it).
244
+ * @param {number} [n]
245
+ * @returns {{ cycles: number, halted: boolean }}
246
+ */
247
+ step(n = 1) {
248
+ const sys = this.system;
249
+ const start = sys.cpu.cycles;
250
+ const debug = this.debug;
251
+ if (this.#busy) throw new Error('step: the machine is already running; step and run cannot be called from a when or until callback');
252
+ this.#busy = true;
253
+ this.#checkBreaks = false;
254
+ try {
255
+ for (let k = 0; k < n; k++) {
256
+ sys.stepInstruction(this.#hook);
257
+ if (debug.active) debug.afterInstruction();
258
+ }
259
+ } catch (e) {
260
+ // A hook or callback that threw leaves no instruction open, no edge recorded for it, and no stop pending for the next call.
261
+ if (debug.active) {
262
+ debug.endInstruction();
263
+ debug.afterInstruction();
264
+ }
265
+ throw e;
266
+ } finally {
267
+ this.#busy = false;
268
+ }
269
+ return { cycles: sys.cpu.cycles - start, halted: sys.cpu.halted };
270
+ }
271
+
272
+ /**
273
+ * Run until a T-state budget is spent or a condition holds, both checked after each
274
+ * instruction; at least one instruction always runs. The condition wins a tie with the budget.
275
+ * A breakpoint stops the run before its instruction executes, and the next run starts past it
276
+ * (only at that PC, so hit counts do not depend on how a session is split into runs). A
277
+ * breakpoint at a run's first boundary that it did not just stop at stops with `cycles: 0`
278
+ * (an interrupt response at that boundary counts). Breakpoints are not checked while halted.
279
+ * A watch with `stop` returns after the instruction whose access hit it; after an instruction
280
+ * the order is watch stop, then `until`, then the budget. `step` logs watch hits but never stops.
281
+ * A stop watch hit by an interrupt response at a boundary a breakpoint then holds stays pending:
282
+ * the next run returns `'watch'` after the held instruction (a `step` in between drops it).
283
+ * @param {{ cycles?: number, until?: Until }} opts
284
+ * @returns {{ reason: 'cycles' | 'halt' | 'predicate', cycles: number } | { reason: 'break' | 'watch', id: number, cycles: number }}
285
+ */
286
+ run({ cycles, until } = {}) {
287
+ if (cycles === undefined && until === undefined) throw new Error('run needs cycles, until, or both; with neither it would never stop');
288
+ 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)}`);
289
+ if (until !== undefined && until !== 'halt' && typeof until !== 'function') throw new Error(`run: until must be 'halt' or a function, not ${String(until)}`);
290
+ const sys = this.system;
291
+ const cpu = sys.cpu;
292
+ const start = cpu.cycles;
293
+ const target = cycles === undefined ? Infinity : start + cycles;
294
+ const debug = this.debug;
295
+ if (this.#busy) throw new Error('run: the machine is already running; step and run cannot be called from a when or until callback');
296
+ this.#busy = true;
297
+ this.#checkBreaks = true;
298
+ this.#firstBoundary = true;
299
+ this.#stopped = false;
300
+ try {
301
+ for (;;) {
302
+ sys.stepInstruction(this.#hook);
303
+ if (this.#stopped) {
304
+ this.#stopped = false;
305
+ // A watch stop from this boundary's interrupt response stays pending: the next run returns it after the held instruction.
306
+ debug.endInstruction();
307
+ const id = /** @type {number} */ (debug.stopId);
308
+ debug.stopId = null;
309
+ return { reason: 'break', id, cycles: cpu.cycles - start };
310
+ }
311
+ if (debug.active) {
312
+ const w = debug.afterInstruction();
313
+ if (w !== null) return { reason: 'watch', id: w, cycles: cpu.cycles - start };
314
+ }
315
+ if (until === 'halt') {
316
+ if (cpu.halted) return { reason: 'halt', cycles: cpu.cycles - start };
317
+ } else if (until !== undefined && until(this)) {
318
+ return { reason: 'predicate', cycles: cpu.cycles - start };
319
+ }
320
+ if (cpu.cycles >= target) return { reason: 'cycles', cycles: cpu.cycles - start };
321
+ }
322
+ } catch (e) {
323
+ // A hook or callback that threw leaves no instruction open, no edge recorded for it, and no stop pending for the next call.
324
+ if (debug.active) {
325
+ debug.endInstruction();
326
+ debug.afterInstruction();
327
+ }
328
+ throw e;
329
+ } finally {
330
+ this.#busy = false;
331
+ }
332
+ }
333
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * TRS-80 memory map, by model (src/machine/models.js holds the sources for the Model I facts):
3
+ *
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.
32
+ */
33
+
34
+ import { MODEL_III } from "./models.js";
35
+
36
+ const KEYBOARD_BASE = 0x3800;
37
+ const KEYBOARD_LIMIT = 0x3c00;
38
+ const RAM_BASE = 0x4000;
39
+ const EXPANSION_BASE = 0x37e0;
40
+ const EXPANSION_LIMIT = 0x37f0;
41
+
42
+ export class MemorySystem {
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
+
60
+ // ROM storage is 16K so padded 16K images can be loaded verbatim,
61
+ // but only 0x0000 up to the model's ROM top is ever mapped as ROM.
62
+ this.rom = new Uint8Array(0x4000);
63
+ this.romLimit = model.romTop;
64
+
65
+ // RAM from 0x4000: 48K reaches 0xFFFF
66
+ this.ram = new Uint8Array(ram * 1024);
67
+ this.ramLimit = RAM_BASE + this.ram.length;
68
+
69
+ // 1K video RAM (0x3C00 - 0x3FFF)
70
+ this.videoRam = new Uint8Array(0x400);
71
+
72
+ // Set on every video RAM write so the renderer can skip idle frames
73
+ this.videoDirty = false;
74
+
75
+ // Keyboard matrix peripheral: { read(addrLow8) -> row bits } or null
76
+ this.keyboard = null;
77
+
78
+ this.romLoaded = false;
79
+
80
+ // Kept for API compatibility with existing callers
81
+ this.VIDEO_RAM_START = 0x3c00;
82
+ this.VIDEO_RAM_END = 0x3fff;
83
+ }
84
+
85
+ /**
86
+ * Get memory statistics
87
+ */
88
+ getStats() {
89
+ return {
90
+ romSize: this.rom.length,
91
+ ramSize: this.ram.length,
92
+ totalSize: this.rom.length + this.ram.length,
93
+ romLoaded: this.romLoaded,
94
+ };
95
+ }
96
+
97
+ /**
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.
102
+ */
103
+ loadROM(romData) {
104
+ if (!romData || romData.length === 0) {
105
+ throw new Error("ROM data is required");
106
+ }
107
+
108
+ if (!this.model.romSizes.includes(romData.length)) {
109
+ throw new Error(
110
+ `Invalid ROM size: ${romData.length} bytes. ${this.model.romSizeText}`
111
+ );
112
+ }
113
+
114
+ this.rom.fill(0);
115
+ this.rom.set(romData, 0);
116
+
117
+ this.romLoaded = true;
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * Read a byte from memory
123
+ * Addresses are masked to 16 bits (0x0000 - 0xFFFF)
124
+ */
125
+ readByte(address) {
126
+ address = address & 0xffff;
127
+
128
+ if (address < this.romLimit) {
129
+ return this.rom[address];
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
+ }
138
+ if (address < KEYBOARD_LIMIT) {
139
+ // Keyboard matrix: reading with no keys down yields 0x00
140
+ return this.keyboard ? this.keyboard.read(address & 0xff) : 0x00;
141
+ }
142
+ if (address < RAM_BASE) {
143
+ return this.videoRam[address - KEYBOARD_LIMIT];
144
+ }
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);
162
+ }
163
+
164
+ /**
165
+ * Write a byte to memory
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.
169
+ */
170
+ writeByte(address, value) {
171
+ address = address & 0xffff;
172
+ value = value & 0xff;
173
+
174
+ if (address < KEYBOARD_LIMIT) {
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;
180
+ }
181
+ if (address < RAM_BASE) {
182
+ if (this.videoBit6Gate) value = (value & 0xbf) | (value & 0xa0 ? 0 : 0x40);
183
+ this.videoRam[address - KEYBOARD_LIMIT] = value;
184
+ this.videoDirty = true;
185
+ return;
186
+ }
187
+ if (address < this.ramLimit) {
188
+ this.ram[address - RAM_BASE] = value;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Read a 16-bit word (little-endian)
194
+ */
195
+ readWord(address) {
196
+ address = address & 0xffff;
197
+ const low = this.readByte(address);
198
+ const high = this.readByte((address + 1) & 0xffff);
199
+ return (high << 8) | low;
200
+ }
201
+
202
+ /**
203
+ * Write a 16-bit word (little-endian)
204
+ */
205
+ writeWord(address, value) {
206
+ address = address & 0xffff;
207
+ value = value & 0xffff;
208
+ const low = value & 0xff;
209
+ const high = (value >> 8) & 0xff;
210
+ this.writeByte(address, low);
211
+ this.writeByte((address + 1) & 0xffff, high);
212
+ }
213
+
214
+ /**
215
+ * Load a program into RAM
216
+ * @param {Uint8Array|Array} program - Program data
217
+ * @param {number} address - Load address (default: 0x4200)
218
+ * @returns {number} The address where the program was loaded
219
+ */
220
+ loadProgram(program, address = 0x4200) {
221
+ if (!program || program.length === 0) {
222
+ return address;
223
+ }
224
+
225
+ // Convert to Uint8Array if needed
226
+ let programData;
227
+ if (program instanceof Uint8Array) {
228
+ programData = program;
229
+ } else if (Array.isArray(program)) {
230
+ programData = new Uint8Array(program);
231
+ } else {
232
+ throw new Error("Program must be Uint8Array or Array");
233
+ }
234
+
235
+ address = address & 0xffff;
236
+
237
+ // Check if program fits in memory
238
+ if (address < RAM_BASE) {
239
+ throw new Error("Cannot load program into ROM area");
240
+ }
241
+
242
+ const endAddress = (address + programData.length - 1) & 0xffff;
243
+ if (endAddress < address) {
244
+ // Wrapped around - program too large
245
+ throw new Error("Program exceeds available memory");
246
+ }
247
+
248
+ // Load program into RAM
249
+ for (let i = 0; i < programData.length; i++) {
250
+ const addr = (address + i) & 0xffff;
251
+ this.writeByte(addr, programData[i]);
252
+ }
253
+
254
+ return address;
255
+ }
256
+
257
+ /**
258
+ * Clear all RAM (set to 0x00)
259
+ */
260
+ clearRAM() {
261
+ this.ram.fill(0);
262
+ }
263
+
264
+ /**
265
+ * Clear video RAM to spaces (what the ROM's CLS leaves behind)
266
+ */
267
+ clearVideoRAM() {
268
+ this.videoRam.fill(0x20);
269
+ this.videoDirty = true;
270
+ }
271
+
272
+ /**
273
+ * Get a view of the entire memory space (for debugging)
274
+ * Returns a Uint8Array representing the full 64K address space
275
+ */
276
+ getMemoryView() {
277
+ const view = new Uint8Array(0x10000);
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);
282
+ view.set(this.videoRam, KEYBOARD_LIMIT);
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
+ }
288
+ return view;
289
+ }
290
+ }