@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.
- package/LICENSE +21 -0
- package/NOTICE.md +27 -0
- package/README.md +235 -2
- package/package.json +30 -3
- package/src/cpu/z80cpu.js +3350 -0
- package/src/debug/debugger.js +788 -0
- package/src/debug/instr.js +90 -0
- package/src/debug/label-file.js +429 -0
- package/src/debug/state.js +273 -0
- package/src/devices/cassette-tape.js +254 -0
- package/src/devices/cassette.js +263 -0
- package/src/devices/disk-image.js +965 -0
- package/src/devices/fdc-wd1771.js +180 -0
- package/src/devices/fdc-wd1793.js +1065 -0
- package/src/devices/interrupt-latch.js +76 -0
- package/src/devices/key-queue.js +278 -0
- package/src/devices/keyboard.js +176 -0
- package/src/devices/printer.js +44 -0
- package/src/devices/sound.js +53 -0
- package/src/devices/video.js +69 -0
- package/src/index.js +23 -0
- package/src/loaders/bas.js +107 -0
- package/src/loaders/cas-format.js +213 -0
- package/src/loaders/cmd-format.js +88 -0
- package/src/loaders/handoff.js +29 -0
- package/src/loaders/load.js +299 -0
- package/src/machine/drive-select.js +42 -0
- package/src/machine/io.js +513 -0
- package/src/machine/machine.js +333 -0
- package/src/machine/memory.js +290 -0
- package/src/machine/models.js +123 -0
- package/src/machine/rom-hashes.js +21 -0
- package/src/machine/sha256.js +74 -0
- package/src/machine/trs80-system.js +289 -0
|
@@ -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
|
+
}
|