@fugloy/emu 0.0.0-stage → 0.2.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,207 @@
1
+ /**
2
+ * TRS-80 Model III Memory Management System
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
10
+ */
11
+
12
+ const ROM_LIMIT = 0x3800;
13
+ const KEYBOARD_LIMIT = 0x3c00;
14
+ const RAM_BASE = 0x4000;
15
+
16
+ export class MemorySystem {
17
+ constructor() {
18
+ // ROM storage is 16K so padded 16K images can be loaded verbatim,
19
+ // but only 0x0000-0x37FF is ever mapped as ROM.
20
+ this.rom = new Uint8Array(0x4000);
21
+
22
+ // 48K RAM (0x4000 - 0xFFFF)
23
+ this.ram = new Uint8Array(0xc000);
24
+
25
+ // 1K video RAM (0x3C00 - 0x3FFF)
26
+ this.videoRam = new Uint8Array(0x400);
27
+
28
+ // Set on every video RAM write so the renderer can skip idle frames
29
+ this.videoDirty = false;
30
+
31
+ // Keyboard matrix peripheral: { read(addrLow8) -> row bits } or null
32
+ this.keyboard = null;
33
+
34
+ this.romLoaded = false;
35
+
36
+ // Kept for API compatibility with existing callers
37
+ this.VIDEO_RAM_START = 0x3c00;
38
+ this.VIDEO_RAM_END = 0x3fff;
39
+ }
40
+
41
+ /**
42
+ * Get memory statistics
43
+ */
44
+ getStats() {
45
+ return {
46
+ romSize: this.rom.length,
47
+ ramSize: this.ram.length,
48
+ totalSize: this.rom.length + this.ram.length,
49
+ romLoaded: this.romLoaded,
50
+ };
51
+ }
52
+
53
+ /**
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.
57
+ */
58
+ loadROM(romData) {
59
+ if (!romData || romData.length === 0) {
60
+ throw new Error("ROM data is required");
61
+ }
62
+
63
+ if (romData.length !== 0x3800 && romData.length !== 0x4000) {
64
+ throw new Error(
65
+ `Invalid ROM size: ${romData.length} bytes. Expected 14KB (0x3800) or a 16KB (0x4000) padded image`
66
+ );
67
+ }
68
+
69
+ this.rom.fill(0);
70
+ this.rom.set(romData, 0);
71
+
72
+ this.romLoaded = true;
73
+ return true;
74
+ }
75
+
76
+ /**
77
+ * Read a byte from memory
78
+ * Addresses are masked to 16 bits (0x0000 - 0xFFFF)
79
+ */
80
+ readByte(address) {
81
+ address = address & 0xffff;
82
+
83
+ if (address < ROM_LIMIT) {
84
+ return this.rom[address];
85
+ }
86
+ if (address < KEYBOARD_LIMIT) {
87
+ // Keyboard matrix: reading with no keys down yields 0x00
88
+ return this.keyboard ? this.keyboard.read(address & 0xff) : 0x00;
89
+ }
90
+ if (address < RAM_BASE) {
91
+ return this.videoRam[address - KEYBOARD_LIMIT];
92
+ }
93
+ return this.ram[address - RAM_BASE];
94
+ }
95
+
96
+ /**
97
+ * Write a byte to memory
98
+ * ROM and the keyboard region ignore writes; video RAM and RAM accept them.
99
+ */
100
+ writeByte(address, value) {
101
+ address = address & 0xffff;
102
+ value = value & 0xff;
103
+
104
+ if (address < KEYBOARD_LIMIT) {
105
+ return; // ROM / keyboard matrix: not writable
106
+ }
107
+ if (address < RAM_BASE) {
108
+ this.videoRam[address - KEYBOARD_LIMIT] = value;
109
+ this.videoDirty = true;
110
+ return;
111
+ }
112
+ this.ram[address - RAM_BASE] = value;
113
+ }
114
+
115
+ /**
116
+ * Read a 16-bit word (little-endian)
117
+ */
118
+ readWord(address) {
119
+ address = address & 0xffff;
120
+ const low = this.readByte(address);
121
+ const high = this.readByte((address + 1) & 0xffff);
122
+ return (high << 8) | low;
123
+ }
124
+
125
+ /**
126
+ * Write a 16-bit word (little-endian)
127
+ */
128
+ writeWord(address, value) {
129
+ address = address & 0xffff;
130
+ value = value & 0xffff;
131
+ const low = value & 0xff;
132
+ const high = (value >> 8) & 0xff;
133
+ this.writeByte(address, low);
134
+ this.writeByte((address + 1) & 0xffff, high);
135
+ }
136
+
137
+ /**
138
+ * Load a program into RAM
139
+ * @param {Uint8Array|Array} program - Program data
140
+ * @param {number} address - Load address (default: 0x4200)
141
+ * @returns {number} The address where the program was loaded
142
+ */
143
+ loadProgram(program, address = 0x4200) {
144
+ if (!program || program.length === 0) {
145
+ return address;
146
+ }
147
+
148
+ // Convert to Uint8Array if needed
149
+ let programData;
150
+ if (program instanceof Uint8Array) {
151
+ programData = program;
152
+ } else if (Array.isArray(program)) {
153
+ programData = new Uint8Array(program);
154
+ } else {
155
+ throw new Error("Program must be Uint8Array or Array");
156
+ }
157
+
158
+ address = address & 0xffff;
159
+
160
+ // Check if program fits in memory
161
+ if (address < RAM_BASE) {
162
+ throw new Error("Cannot load program into ROM area");
163
+ }
164
+
165
+ const endAddress = (address + programData.length - 1) & 0xffff;
166
+ if (endAddress < address) {
167
+ // Wrapped around - program too large
168
+ throw new Error("Program exceeds available memory");
169
+ }
170
+
171
+ // Load program into RAM
172
+ for (let i = 0; i < programData.length; i++) {
173
+ const addr = (address + i) & 0xffff;
174
+ this.writeByte(addr, programData[i]);
175
+ }
176
+
177
+ return address;
178
+ }
179
+
180
+ /**
181
+ * Clear all RAM (set to 0x00)
182
+ */
183
+ clearRAM() {
184
+ this.ram.fill(0);
185
+ }
186
+
187
+ /**
188
+ * Clear video RAM to spaces (what the ROM's CLS leaves behind)
189
+ */
190
+ clearVideoRAM() {
191
+ this.videoRam.fill(0x20);
192
+ this.videoDirty = true;
193
+ }
194
+
195
+ /**
196
+ * Get a view of the entire memory space (for debugging)
197
+ * Returns a Uint8Array representing the full 64K address space
198
+ */
199
+ getMemoryView() {
200
+ 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
203
+ view.set(this.videoRam, KEYBOARD_LIMIT);
204
+ view.set(this.ram, RAM_BASE);
205
+ return view;
206
+ }
207
+ }
@@ -0,0 +1,14 @@
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.
5
+ *
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.
10
+ */
11
+ export const ROM_HASHES = Object.freeze({
12
+ beeb3741175a8b8706cc384d5b2534ebf32e383662864054e5a0ce4d861a43bb: 'model3-rev-c-2ef8',
13
+ ad6de30c6aece2d6a2243ee90a2959295d5c46b82650b27f46f1ecf8b34b2361: 'model3-rev-c-frehd',
14
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * SHA-256 (FIPS 180-4) in plain JavaScript, because the core may use neither Node's crypto nor
3
+ * the browser's asynchronous Web Crypto. A copy of @fugloy/disasm's `src/hash.ts` (emu must not
4
+ * depend on disasm); identifies ROM images against ROM_HASHES.
5
+ */
6
+ const K = new Uint32Array([
7
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
8
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
9
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
10
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
11
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
12
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
13
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
14
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
15
+ ]);
16
+
17
+ const H0 = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
18
+
19
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
20
+
21
+ /**
22
+ * @param {Uint8Array} bytes
23
+ * @returns {Uint8Array} the 32-byte digest
24
+ */
25
+ export function sha256(bytes) {
26
+ const len = bytes.length;
27
+ const padded = new Uint8Array(((len + 9 + 63) >> 6) << 6);
28
+ padded.set(bytes);
29
+ padded[len] = 0x80;
30
+ const view = new DataView(padded.buffer);
31
+ const bits = len * 8;
32
+ view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000));
33
+ view.setUint32(padded.length - 4, bits >>> 0);
34
+
35
+ const h = Uint32Array.from(H0);
36
+ const w = new Uint32Array(64);
37
+ for (let off = 0; off < padded.length; off += 64) {
38
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
39
+ for (let i = 16; i < 64; i++) {
40
+ const w15 = w[i - 15];
41
+ const w2 = w[i - 2];
42
+ const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3);
43
+ const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10);
44
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
45
+ }
46
+ let a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
47
+ for (let i = 0; i < 64; i++) {
48
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
49
+ const ch = (e & f) ^ (~e & g);
50
+ const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0;
51
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
52
+ const maj = (a & b) ^ (a & c) ^ (b & c);
53
+ const t2 = (S0 + maj) >>> 0;
54
+ hh = g; g = f; f = e;
55
+ e = (d + t1) >>> 0;
56
+ d = c; c = b; b = a;
57
+ a = (t1 + t2) >>> 0;
58
+ }
59
+ h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0;
60
+ h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0;
61
+ }
62
+ const out = new Uint8Array(32);
63
+ const outView = new DataView(out.buffer);
64
+ for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i]);
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * @param {Uint8Array} bytes
70
+ * @returns {string} the digest as 64 lowercase hex digits
71
+ */
72
+ export function sha256Hex(bytes) {
73
+ return Array.from(sha256(bytes), (b) => b.toString(16).padStart(2, '0')).join('');
74
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * TRS-80 Model III System
3
+ *
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.
8
+ *
9
+ * Rendering is the caller's concern: read `memory.videoRam` (or
10
+ * `screenText()`) and consult `memory.videoDirty`. Keyboard input goes
11
+ * through `system.keyboard` (press and release keys directly).
12
+ */
13
+
14
+ import { Z80CPU } from "../cpu/z80cpu.js";
15
+ import { MemorySystem } from "./memory.js";
16
+ import { IOSystem, MODE_32COL_BIT } from "./io.js";
17
+ import { KeyboardMatrix } from "../devices/keyboard.js";
18
+
19
+ export const CPU_CLOCK_HZ = 2027520;
20
+ export const RTC_HZ = 30;
21
+ export const RTC_INTERVAL_TSTATES = CPU_CLOCK_HZ / RTC_HZ; // 67,584
22
+
23
+ export class TRS80System {
24
+ constructor({ romData, log }) {
25
+ this.memory = new MemorySystem();
26
+ this.io = new IOSystem();
27
+ this.keyboard = new KeyboardMatrix();
28
+ this.cpu = new Z80CPU();
29
+ if (log !== undefined) {
30
+ this.cpu.log = log;
31
+ this.io.cassette.log = log;
32
+ }
33
+
34
+ this.memory.keyboard = this.keyboard;
35
+
36
+ this.cpu.readMemory = (addr) => this.memory.readByte(addr);
37
+ this.cpu.writeMemory = (addr, value) => this.memory.writeByte(addr, value);
38
+ this.cpu.readPort = (port) => this.io.readPort(port);
39
+ this.cpu.writePort = (port, value) => this.io.writePort(port, value);
40
+
41
+ // A mode-register change (e.g. the ROM handling CHR$(23)) redraws
42
+ // the same video RAM in a different layout — force a repaint.
43
+ this.io.onModeWrite = () => {
44
+ this.memory.videoDirty = true;
45
+ };
46
+
47
+ // Cassette-out sound transitions are stamped in CPU T-states
48
+ this.io.getCycles = () => this.cpu.cycles;
49
+
50
+ this.memory.loadROM(romData);
51
+
52
+ this.nextRtcAt = RTC_INTERVAL_TSTATES;
53
+ this.reset();
54
+ }
55
+
56
+ /**
57
+ * Hardware reset (the orange button): CPU restarts at 0x0000, interrupt
58
+ * latches clear, screen blanks. RAM contents survive, as on the real
59
+ * machine.
60
+ */
61
+ reset() {
62
+ this.cpu.reset();
63
+ this.io.reset();
64
+ this.keyboard.reset();
65
+ this.memory.clearVideoRAM();
66
+ this.nextRtcAt = this.cpu.cycles + RTC_INTERVAL_TSTATES;
67
+ this._nmiWasPending = false;
68
+ }
69
+
70
+ /**
71
+ * Execute instructions until `budget` T-states have elapsed, raising
72
+ * the RTC heartbeat on schedule and delivering pending interrupts
73
+ * between instructions. Returns the T-states actually consumed (the
74
+ * final instruction may overshoot slightly).
75
+ */
76
+ runTStates(budget) {
77
+ const start = this.cpu.cycles;
78
+ const target = start + budget;
79
+
80
+ while (this.cpu.cycles < target) {
81
+ this.stepInstruction();
82
+ }
83
+
84
+ return this.cpu.cycles - start;
85
+ }
86
+
87
+ /**
88
+ * One instruction boundary, the only place the machine advances: raise
89
+ * the RTC heartbeat when its T-state is due, deliver a pending
90
+ * interrupt or NMI edge, then call `beforeExecute` (if given) and
91
+ * execute one instruction, ticking the disk controller by its cost.
92
+ * `runTStates` and the Machine facade both step through here, so the
93
+ * RTC keeps one schedule however the machine is driven. Returns the
94
+ * T-states consumed, an interrupt response included.
95
+ * @param {() => boolean | 'stop' | void} [beforeExecute] called after interrupt delivery, with PC at the instruction about to run; returning true means it stood in for that instruction, which then does not execute; returning 'stop' means no instruction executes at all (a debugger break)
96
+ * @returns {number}
97
+ */
98
+ stepInstruction(beforeExecute) {
99
+ const start = this.cpu.cycles;
100
+ if (this.cpu.cycles >= this.nextRtcAt) {
101
+ this.io.raiseRTC();
102
+ this.nextRtcAt += RTC_INTERVAL_TSTATES;
103
+ }
104
+ if (this.io.pendingInterrupt()) {
105
+ this.cpu.interrupt();
106
+ }
107
+ // FDC INTRQ -> NMI, edge-triggered so one assertion fires one NMI
108
+ const nmiNow = this.io.nmiPending();
109
+ if (nmiNow && !this._nmiWasPending) {
110
+ this.cpu.nmi();
111
+ }
112
+ this._nmiWasPending = nmiNow;
113
+
114
+ // beforeExecute returning true has executed the boundary's instruction itself (the
115
+ // 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;
123
+ }
124
+ if (r !== true) this.cpu.executeInstruction();
125
+ } else {
126
+ this.cpu.executeInstruction();
127
+ }
128
+ this.io.fdc.tick(this.cpu.cycles - before);
129
+ return this.cpu.cycles - start;
130
+ }
131
+
132
+ /**
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.
136
+ */
137
+ get columns32() {
138
+ return (this.io.modeRegister & MODE_32COL_BIT) !== 0;
139
+ }
140
+
141
+ /** Mount a .dsk image (DiskImage) in drive 0-3. */
142
+ mountDisk(driveNumber, image) {
143
+ this.io.fdc.attachDrive(driveNumber, image);
144
+ }
145
+
146
+ /**
147
+ * Eject every drive and reboot into cassette BASIC, answering the
148
+ * ROM's Cass? and Memory Size? prompts. Cassette-world loaders (the
149
+ * library, .cas tapes, paste-BASIC) need the machine at READY — under
150
+ * a booted DOS their typed RUN goes to the DOS prompt instead. Runs
151
+ * ~3.5 emulated seconds inline, a blink of wall clock.
152
+ * @returns {boolean} true when the READY prompt was reached
153
+ */
154
+ bootToCassetteBasic() {
155
+ for (let drive = 0; drive < 4; drive++) {
156
+ this.ejectDisk(drive);
157
+ }
158
+ this.reset();
159
+ const pressEnter = () => {
160
+ this.keyboard.keyDown("Enter", "bootdance");
161
+ this.runTStates(200000);
162
+ this.keyboard.keyUp("bootdance");
163
+ this.runTStates(100000);
164
+ };
165
+ this.runSeconds(1);
166
+ pressEnter();
167
+ this.runSeconds(0.5);
168
+ pressEnter();
169
+ this.runSeconds(2);
170
+ return this.screenText().join("\n").includes("READY");
171
+ }
172
+
173
+ ejectDisk(driveNumber) {
174
+ this.io.fdc.ejectDrive(driveNumber);
175
+ }
176
+
177
+ /** Convenience for tests: run N emulated seconds. */
178
+ runSeconds(seconds) {
179
+ return this.runTStates(Math.round(seconds * CPU_CLOCK_HZ));
180
+ }
181
+
182
+ /**
183
+ * Type text through the keyboard matrix at emulated speed ("turbo
184
+ * paste"): each key is held long enough for the ROM's scan/debounce,
185
+ * but the wall-clock cost is only the emulation time (~3 ms/char).
186
+ * '\n' presses ENTER. Returns the number of characters that had no
187
+ * matrix mapping and were skipped.
188
+ *
189
+ * After ENTER the ROM tokenizes and stores the line (cost grows with
190
+ * program size) before it scans the keyboard again — give it room or
191
+ * the first character of the next line gets eaten. The default suits
192
+ * the small built-in classics; pass a larger `enterTStates` for
193
+ * multi-KB listings (a 5.7 KB program needs ~1M).
194
+ */
195
+ typeText(text, { enterTStates = 400000 } = {}) {
196
+ let skipped = 0;
197
+ let seq = 0;
198
+ for (const ch of text) {
199
+ const key = ch === "\n" ? "Enter" : ch;
200
+ const code = `typeText:${seq++}`;
201
+ if (!this.keyboard.keyDown(key, code)) {
202
+ skipped++;
203
+ continue;
204
+ }
205
+ this.runTStates(70000); // held across several scans + debounce
206
+ this.keyboard.keyUp(code);
207
+ this.runTStates(ch === "\n" ? enterTStates : 50000);
208
+ }
209
+ return skipped;
210
+ }
211
+
212
+ /**
213
+ * The 64x16 screen as strings, for tests and status displays.
214
+ * Control bytes 0x00-0x1F display as their 0x40-0x5F fold (Model III
215
+ * character generator); block-graphics bytes render as '.'.
216
+ */
217
+ screenText() {
218
+ const rows = [];
219
+ for (let row = 0; row < 16; row++) {
220
+ let line = "";
221
+ for (let col = 0; col < 64; col++) {
222
+ let byte = this.memory.videoRam[row * 64 + col];
223
+ if (byte < 0x20) {
224
+ byte += 0x40;
225
+ }
226
+ line += byte >= 0x20 && byte < 0x80 ? String.fromCharCode(byte) : ".";
227
+ }
228
+ rows.push(line);
229
+ }
230
+ return rows;
231
+ }
232
+ }