@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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model descriptions: what differs between the TRS-80 models the Machine builds, as data, so the
|
|
3
|
+
* memory map, the I/O ports, the clock, and the ROM checks read one description instead of
|
|
4
|
+
* hard-coding the Model III (spec §5).
|
|
5
|
+
*
|
|
6
|
+
* Model I facts and where they come from (the ROM sources are github.com/kiwisincebirth/TRS-80-ROMS;
|
|
7
|
+
* spec §5 lists what is still unverified against the Model I Technical Reference Manual):
|
|
8
|
+
* - ROM: 12 KB at $0000-$2FFF. The repository's Model I Level II images `test-roms/M1L2_1.2.bin`
|
|
9
|
+
* and `M1L2_1.3.bin` are 12,288 bytes each, and its MODEL1.md describes `MDL1LEV2.Z80` as the
|
|
10
|
+
* source of the "Model I Level 2 12KB ROMS".
|
|
11
|
+
* - Keyboard matrix at $3800, video RAM at $3C00 (400H bytes), RAM from $4000: CONSTANTS.Z80,
|
|
12
|
+
* "Memory Mapped Hardware" and "RAM Communications Area".
|
|
13
|
+
* - $37E0-$37EF belong to the Expansion Interface (drive select $37E1, cassette select $37E4,
|
|
14
|
+
* printer $37E8, the disk controller $37EC-$37EF: CONSTANTS.Z80, "Memory Mapped Hardware,
|
|
15
|
+
* Model 1"). Without the Expansion Interface those addresses read $FF: MDL1LEV2.Z80 at $0069
|
|
16
|
+
* ("$FF if no expansion interface present") and $0696-$069C, where the ROM takes $FF (or $00) at
|
|
17
|
+
* $37EC as "no disk controller" and goes to Level II BASIC. The rest of $3000-$37FF decodes to
|
|
18
|
+
* nothing and is taken to read $FF as well (unverified).
|
|
19
|
+
* - RAM sizes 4, 16, 32, and 48 KB from $4000 (spec §5, unverified against the manual; Ruling 2
|
|
20
|
+
* of the phase 6 plan). Above the fitted RAM a read is taken to float to $FF and a write is
|
|
21
|
+
* lost (unverified); the ROM's MEMORY SIZE? probe at $00C4-$00D4 needs only that a byte there
|
|
22
|
+
* does not hold the complement written to it.
|
|
23
|
+
* - Clock 1,774,080 Hz: spec §5's 1.774 MHz, taken as the 10.6445 MHz crystal divided by 6 (as
|
|
24
|
+
* xtrs runs it; unverified against the manual). The ROM's PAUSE loop at $0060 is commented
|
|
25
|
+
* "BC times 14.65" µs at $06AF, and one pass of it is 26 T-states, which puts the clock at
|
|
26
|
+
* 1.775 MHz.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ id: 1 | 3, name: string, clockHz: number, timerHz: number, romTop: number,
|
|
31
|
+
* romSizes: readonly number[], romSizeText: string, ram: readonly number[], defaultRam: number,
|
|
32
|
+
* ramNote: string, roms: readonly string[], romTitle: string, stateMachine: string }} Model
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** @type {Model} */
|
|
36
|
+
export const MODEL_I = Object.freeze({
|
|
37
|
+
id: 1,
|
|
38
|
+
name: 'Model I',
|
|
39
|
+
clockHz: 1774080,
|
|
40
|
+
// The Expansion Interface's 40 Hz clock, raised through its latch at $37E0
|
|
41
|
+
// (devices/interrupt-latch.js, which cites it); none without the Expansion Interface.
|
|
42
|
+
timerHz: 40,
|
|
43
|
+
romTop: 0x3000,
|
|
44
|
+
romSizes: Object.freeze([0x3000]),
|
|
45
|
+
romSizeText: "The Model I's Level II ROM is 12KB (0x3000)",
|
|
46
|
+
ram: Object.freeze([4, 16, 32, 48]),
|
|
47
|
+
defaultRam: 48,
|
|
48
|
+
ramNote: '',
|
|
49
|
+
roms: Object.freeze(['model1-level2-1.2', 'model1-level2-1.3']),
|
|
50
|
+
romTitle: 'the Model I Level II ROM',
|
|
51
|
+
stateMachine: 'trs80-model1',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** @type {Model} */
|
|
55
|
+
export const MODEL_III = Object.freeze({
|
|
56
|
+
id: 3,
|
|
57
|
+
name: 'Model III',
|
|
58
|
+
clockHz: 2027520,
|
|
59
|
+
timerHz: 30,
|
|
60
|
+
romTop: 0x3800,
|
|
61
|
+
// The real 14K image, or a 16K padded image of it (only $0000-$37FF is mapped).
|
|
62
|
+
romSizes: Object.freeze([0x3800, 0x4000]),
|
|
63
|
+
romSizeText: 'Expected 14KB (0x3800) or a 16KB (0x4000) padded image',
|
|
64
|
+
ram: Object.freeze([48]),
|
|
65
|
+
defaultRam: 48,
|
|
66
|
+
// Ruling 2 of the phase 6 plan: 16K and 32K are cheap but would run untested programs.
|
|
67
|
+
ramNote: '; 16K and 32K come in a later phase',
|
|
68
|
+
roms: Object.freeze(['model3-rev-c-2ef8', 'model3-rev-c-frehd']),
|
|
69
|
+
romTitle: 'the Model III ROM',
|
|
70
|
+
stateMachine: 'trs80-model3',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** The models built, by number. */
|
|
74
|
+
export const MODELS = Object.freeze({ 1: MODEL_I, 3: MODEL_III });
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The description of model `n`, or a sentence-long error for a model not built.
|
|
78
|
+
* @param {unknown} n
|
|
79
|
+
* @returns {Model}
|
|
80
|
+
*/
|
|
81
|
+
export function modelOf(n) {
|
|
82
|
+
if (n === 1) return MODEL_I;
|
|
83
|
+
if (n === 3) return MODEL_III;
|
|
84
|
+
if (n === 4) throw new Error('There is no Model 4 yet: it needs a Model 4 ROM and boot disk to prove it boots, and comes in its own plan when one is supplied; use model 1 or 3.');
|
|
85
|
+
throw new Error(`model must be 1 (the Model I) or 3 (the Model III), not ${String(n)}.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Why `ram` (in K) is not a size model `desc` is built with, as a sentence, or null when it is.
|
|
90
|
+
* @param {Model} desc
|
|
91
|
+
* @param {unknown} ram
|
|
92
|
+
* @returns {string | null}
|
|
93
|
+
*/
|
|
94
|
+
export function ramProblem(desc, ram) {
|
|
95
|
+
if (desc.ram.includes(/** @type {number} */ (ram))) return null;
|
|
96
|
+
const sizes = desc.ram.length === 1 ? String(desc.ram[0]) : `${desc.ram.slice(0, -1).join(', ')}, or ${desc.ram.at(-1)}`;
|
|
97
|
+
const got = typeof ram === 'string' ? JSON.stringify(ram) : String(ram);
|
|
98
|
+
return `The ${desc.name} takes ram: ${sizes} (K), not ${got}${desc.ramNote}.`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The CPU clock of model `n`, in Hz.
|
|
103
|
+
* @param {1 | 3} n
|
|
104
|
+
*/
|
|
105
|
+
export function clockHz(n) {
|
|
106
|
+
return modelOf(n).clockHz;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Why a recognised ROM cannot run on model `n`, as a sentence, or null when it can (or is not
|
|
111
|
+
* recognised: an unknown image is checked by its size alone).
|
|
112
|
+
* @param {string | null} recognized the ROM_HASHES name
|
|
113
|
+
* @param {1 | 3} n
|
|
114
|
+
* @returns {string | null}
|
|
115
|
+
*/
|
|
116
|
+
export function romModelProblem(recognized, n) {
|
|
117
|
+
if (recognized === null) return null;
|
|
118
|
+
const wanted = modelOf(n);
|
|
119
|
+
if (wanted.roms.includes(recognized)) return null;
|
|
120
|
+
const owner = [MODEL_I, MODEL_III].find((d) => d.roms.includes(recognized));
|
|
121
|
+
if (owner === undefined) return null;
|
|
122
|
+
return `This ROM is ${owner.romTitle} (${recognized}); it runs on the ${owner.name}, not the ${wanted.name}: construct the Machine with model: ${owner.id}.`;
|
|
123
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known ROM images by SHA-256 (lowercase hex), so the Machine can report which ROM it was given
|
|
3
|
+
* and refuse one given for the other model (spec §5: the core validates by length and a
|
|
4
|
+
* known-hash table). Hashes are facts about the images; the images themselves are never committed
|
|
5
|
+
* or bundled. Each name starts with its model (`model1-`, `model3-`); src/machine/models.js lists
|
|
6
|
+
* which names each model runs.
|
|
7
|
+
*
|
|
8
|
+
* Source for all: github.com/kiwisincebirth/TRS-80-ROMS, folder test-roms/.
|
|
9
|
+
* - `M1L2_1.2.bin` and `M1L2_1.3.bin` (12,288 bytes each): the Model I Level II ROM, versions 1.2
|
|
10
|
+
* and 1.3 (the repository's MODEL1.md: "Both 1.3 and 1.2 versions"), hashed 2026-10-18; 1.3 is
|
|
11
|
+
* the image the repository's fugloy.mcp.json names as roms.model1.
|
|
12
|
+
* - `Model3-RevC-2EF8.bin` (14,336 bytes): the stock Model III Rev C ROM and the image named as
|
|
13
|
+
* roms.model3; `Model3-RevC-FreHD.bin` is the same ROM patched to boot a FreHD hard-disk
|
|
14
|
+
* emulator. Hashed 2026-10-17.
|
|
15
|
+
*/
|
|
16
|
+
export const ROM_HASHES = Object.freeze({
|
|
17
|
+
'3cd3824d2ac2743750d2179951ed812794302b885ccb4f98aa2579b352c4b00e': 'model1-level2-1.2',
|
|
18
|
+
'5515d95d7c19058400a2e91c6930bb557035db5ce093bd96fd017f795c346776': 'model1-level2-1.3',
|
|
19
|
+
beeb3741175a8b8706cc384d5b2534ebf32e383662864054e5a0ce4d861a43bb: 'model3-rev-c-2ef8',
|
|
20
|
+
ad6de30c6aece2d6a2243ee90a2959295d5c46b82650b27f46f1ecf8b34b2361: 'model3-rev-c-frehd',
|
|
21
|
+
});
|
|
@@ -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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRS-80 System, Model III or Model I
|
|
3
|
+
*
|
|
4
|
+
* Owns and wires the machine: Z80 CPU, memory map, I/O ports, and the
|
|
5
|
+
* keyboard matrix, laid out by the model description (models.js). Runs the
|
|
6
|
+
* CPU in T-state-budgeted slices at the model's clock (the Model III's real
|
|
7
|
+
* 2.02752 MHz) and raises the Model III's 30 Hz RTC heartbeat interrupt the
|
|
8
|
+
* ROM's clock/cursor services depend on, or the Model I Expansion
|
|
9
|
+
* Interface's 40 Hz clock, through its latch at $37E0
|
|
10
|
+
* (devices/interrupt-latch.js); a Model I without the Expansion Interface
|
|
11
|
+
* has no clock and takes no interrupts.
|
|
12
|
+
*
|
|
13
|
+
* Rendering is the caller's concern: read `memory.videoRam` (or
|
|
14
|
+
* `screenText()`) and consult `memory.videoDirty`. Keyboard input goes
|
|
15
|
+
* through `system.keyboard` (press and release keys directly).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Z80CPU } from "../cpu/z80cpu.js";
|
|
19
|
+
import { MemorySystem } from "./memory.js";
|
|
20
|
+
import { IOSystem, MODE_32COL_BIT, M1_MODESEL_BIT } from "./io.js";
|
|
21
|
+
import { TIMER_BIT } from "../devices/interrupt-latch.js";
|
|
22
|
+
import { KeyboardMatrix } from "../devices/keyboard.js";
|
|
23
|
+
import { MODEL_III, modelOf } from "./models.js";
|
|
24
|
+
|
|
25
|
+
/** The Model III's clock and RTC; a system's own are `clockHz` and `rtcInterval`. */
|
|
26
|
+
export const CPU_CLOCK_HZ = MODEL_III.clockHz;
|
|
27
|
+
export const RTC_HZ = MODEL_III.timerHz;
|
|
28
|
+
export const RTC_INTERVAL_TSTATES = CPU_CLOCK_HZ / RTC_HZ; // 67,584
|
|
29
|
+
|
|
30
|
+
export class TRS80System {
|
|
31
|
+
/**
|
|
32
|
+
* @param {{ romData: Uint8Array, log?: (level: string, text: string) => void,
|
|
33
|
+
* model?: 1 | 3 | import('./models.js').Model, ram?: number, expansion?: boolean, lowercase?: boolean }} opts
|
|
34
|
+
* the model (a number or a description; the Model III by default), its RAM in K (the model's
|
|
35
|
+
* default), and the Model I's Expansion Interface (fitted by default) and lowercase modification
|
|
36
|
+
* (not fitted by default); the Machine checks them
|
|
37
|
+
*/
|
|
38
|
+
constructor({ romData, log, model = MODEL_III, ram, expansion = true, lowercase = false }) {
|
|
39
|
+
const desc = typeof model === "number" ? modelOf(model) : model;
|
|
40
|
+
this.model = desc;
|
|
41
|
+
this.clockHz = desc.clockHz;
|
|
42
|
+
this.rtcInterval = desc.clockHz / desc.timerHz;
|
|
43
|
+
this.expansion = expansion;
|
|
44
|
+
this.lowercase = lowercase;
|
|
45
|
+
this.memory = new MemorySystem({ model: desc, ram: ram ?? desc.defaultRam, lowercase });
|
|
46
|
+
this.io = new IOSystem({ model: desc, expansion });
|
|
47
|
+
// The Model I's Expansion Interface addresses go to the I/O decode
|
|
48
|
+
if (this.io.latch !== null) this.memory.expansion = this.io;
|
|
49
|
+
// The 32-column bit: port $EC bit 2 on the Model III, port $FF bit 3 (MODESEL) on the Model I
|
|
50
|
+
this._mode32Bit = desc.id === 1 ? M1_MODESEL_BIT : MODE_32COL_BIT;
|
|
51
|
+
this.keyboard = new KeyboardMatrix();
|
|
52
|
+
this.cpu = new Z80CPU();
|
|
53
|
+
if (log !== undefined) {
|
|
54
|
+
this.cpu.log = log;
|
|
55
|
+
this.io.cassette.log = log;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.memory.keyboard = this.keyboard;
|
|
59
|
+
|
|
60
|
+
this.cpu.readMemory = (addr) => this.memory.readByte(addr);
|
|
61
|
+
this.cpu.writeMemory = (addr, value) => this.memory.writeByte(addr, value);
|
|
62
|
+
this.cpu.readPort = (port) => this.io.readPort(port);
|
|
63
|
+
this.cpu.writePort = (port, value) => this.io.writePort(port, value);
|
|
64
|
+
|
|
65
|
+
// A mode-register change (e.g. the ROM handling CHR$(23)) redraws
|
|
66
|
+
// the same video RAM in a different layout — force a repaint.
|
|
67
|
+
this.io.onModeWrite = () => {
|
|
68
|
+
this.memory.videoDirty = true;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Cassette-out sound transitions are stamped in CPU T-states
|
|
72
|
+
this.io.getCycles = () => this.cpu.cycles;
|
|
73
|
+
|
|
74
|
+
// The Model III WAIT (port F4 bit 6): the controller runs ahead to its
|
|
75
|
+
// next DRQ or INTRQ; the CPU's clock catches up, and stepInstruction
|
|
76
|
+
// does not tick the controller for those T-states again. A port write
|
|
77
|
+
// from outside an instruction (a debugger poke) does not wait.
|
|
78
|
+
this._executing = false;
|
|
79
|
+
this._fdcAhead = 0;
|
|
80
|
+
this.io.onWait = (runToDrq) => {
|
|
81
|
+
if (!this._executing) return;
|
|
82
|
+
const held = runToDrq();
|
|
83
|
+
this.cpu.cycles += held;
|
|
84
|
+
this._fdcAhead += held;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
this.memory.loadROM(romData);
|
|
88
|
+
|
|
89
|
+
this.nextRtcAt = this.rtcInterval;
|
|
90
|
+
this.reset();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Hardware reset (the orange button): CPU restarts at 0x0000, interrupt
|
|
95
|
+
* latches clear, screen blanks. RAM contents survive, as on the real
|
|
96
|
+
* machine.
|
|
97
|
+
*/
|
|
98
|
+
reset() {
|
|
99
|
+
this.cpu.reset();
|
|
100
|
+
this.io.reset();
|
|
101
|
+
this.keyboard.reset();
|
|
102
|
+
this.memory.clearVideoRAM();
|
|
103
|
+
this.nextRtcAt = this.cpu.cycles + this.rtcInterval;
|
|
104
|
+
this._nmiWasPending = false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute instructions until `budget` T-states have elapsed, raising
|
|
109
|
+
* the RTC heartbeat on schedule and delivering pending interrupts
|
|
110
|
+
* between instructions. Returns the T-states actually consumed (the
|
|
111
|
+
* final instruction may overshoot slightly).
|
|
112
|
+
*/
|
|
113
|
+
runTStates(budget) {
|
|
114
|
+
const start = this.cpu.cycles;
|
|
115
|
+
const target = start + budget;
|
|
116
|
+
|
|
117
|
+
while (this.cpu.cycles < target) {
|
|
118
|
+
this.stepInstruction();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return this.cpu.cycles - start;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One instruction boundary, the only place the machine advances: raise
|
|
126
|
+
* the RTC heartbeat when its T-state is due, deliver a pending
|
|
127
|
+
* interrupt or NMI edge, then call `beforeExecute` (if given) and
|
|
128
|
+
* execute one instruction, ticking the disk controller by its cost.
|
|
129
|
+
* `runTStates` and the Machine facade both step through here, so the
|
|
130
|
+
* RTC keeps one schedule however the machine is driven. Returns the
|
|
131
|
+
* T-states consumed, an interrupt response included.
|
|
132
|
+
* @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)
|
|
133
|
+
* @returns {number}
|
|
134
|
+
*/
|
|
135
|
+
stepInstruction(beforeExecute) {
|
|
136
|
+
const start = this.cpu.cycles;
|
|
137
|
+
if (this.cpu.cycles >= this.nextRtcAt) {
|
|
138
|
+
if (this.model.id === 3) this.io.raiseRTC();
|
|
139
|
+
else if (this.io.latch !== null) this.io.latch.raise(TIMER_BIT);
|
|
140
|
+
this.nextRtcAt += this.rtcInterval;
|
|
141
|
+
}
|
|
142
|
+
if (this.io.pendingInterrupt()) {
|
|
143
|
+
this.cpu.interrupt();
|
|
144
|
+
}
|
|
145
|
+
// FDC INTRQ -> NMI, edge-triggered so one assertion fires one NMI. The
|
|
146
|
+
// CPU holds an NMI off after a lone DD/FD prefix, as it does a maskable
|
|
147
|
+
// interrupt (Young §3.6); the edge then stays pending for the next
|
|
148
|
+
// boundary.
|
|
149
|
+
const nmiNow = this.io.nmiPending();
|
|
150
|
+
if (nmiNow && !this._nmiWasPending) {
|
|
151
|
+
if (this.cpu.nmi()) this._nmiWasPending = true;
|
|
152
|
+
} else {
|
|
153
|
+
this._nmiWasPending = nmiNow;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The disk controller ticks by every T-state the boundary takes, an interrupt or NMI
|
|
157
|
+
// response included, so its clock (the disk's angle) keeps step with the CPU's: the response
|
|
158
|
+
// first, so an OUT that sets the WAIT finds the controller at the start of that OUT; then the
|
|
159
|
+
// instruction, less the T-states the WAIT already ran it ahead for (`_fdcAhead`).
|
|
160
|
+
const before = this.cpu.cycles;
|
|
161
|
+
if (before !== start) this.io.fdc.tick(before - start);
|
|
162
|
+
|
|
163
|
+
// beforeExecute returning true has executed the boundary's instruction itself (the
|
|
164
|
+
// Machine's ROM keyboard-scan hook stands in for a RET), so the CPU does not run one.
|
|
165
|
+
// Returning 'stop' runs nothing; the return value is then the response's T-states.
|
|
166
|
+
// try/finally: a callback or an instruction that throws must not leave `_executing` set, or
|
|
167
|
+
// a later debugger port poke would wait as if an instruction were running.
|
|
168
|
+
this._executing = true;
|
|
169
|
+
try {
|
|
170
|
+
if (beforeExecute !== undefined) {
|
|
171
|
+
const r = beforeExecute();
|
|
172
|
+
if (r === 'stop') return before - start;
|
|
173
|
+
if (r !== true) this.cpu.executeInstruction();
|
|
174
|
+
} else {
|
|
175
|
+
this.cpu.executeInstruction();
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
this._fdcAhead = 0;
|
|
179
|
+
throw e;
|
|
180
|
+
} finally {
|
|
181
|
+
this._executing = false;
|
|
182
|
+
}
|
|
183
|
+
this.io.fdc.tick(this.cpu.cycles - before - this._fdcAhead);
|
|
184
|
+
this._fdcAhead = 0;
|
|
185
|
+
return this.cpu.cycles - start;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* True when the machine is in 32-character mode (on the Model III the
|
|
190
|
+
* mode register's bit 2, on the Model I port $FF's MODESEL, bit 3; set by
|
|
191
|
+
* the ROM for CHR$(23), cleared by CLS). The ROM lays 32-column text at
|
|
192
|
+
* even video addresses; hardware shows them double-wide.
|
|
193
|
+
*/
|
|
194
|
+
get columns32() {
|
|
195
|
+
return (this.io.modeRegister & this._mode32Bit) !== 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Mount a .dsk image (DiskImage) in drive 0-3. */
|
|
199
|
+
mountDisk(driveNumber, image) {
|
|
200
|
+
this.io.fdc.attachDrive(driveNumber, image);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Eject every drive and reboot into cassette BASIC, answering the
|
|
205
|
+
* ROM's Cass? and Memory Size? prompts. Cassette-world loaders (the
|
|
206
|
+
* library, .cas tapes, paste-BASIC) need the machine at READY — under
|
|
207
|
+
* a booted DOS their typed RUN goes to the DOS prompt instead. Runs
|
|
208
|
+
* ~3.5 emulated seconds inline, a blink of wall clock.
|
|
209
|
+
* @returns {boolean} true when the READY prompt was reached
|
|
210
|
+
*/
|
|
211
|
+
bootToCassetteBasic() {
|
|
212
|
+
for (let drive = 0; drive < 4; drive++) {
|
|
213
|
+
this.ejectDisk(drive);
|
|
214
|
+
}
|
|
215
|
+
this.reset();
|
|
216
|
+
const pressEnter = () => {
|
|
217
|
+
this.keyboard.keyDown("Enter", "bootdance");
|
|
218
|
+
this.runTStates(200000);
|
|
219
|
+
this.keyboard.keyUp("bootdance");
|
|
220
|
+
this.runTStates(100000);
|
|
221
|
+
};
|
|
222
|
+
this.runSeconds(1);
|
|
223
|
+
pressEnter();
|
|
224
|
+
this.runSeconds(0.5);
|
|
225
|
+
pressEnter();
|
|
226
|
+
this.runSeconds(2);
|
|
227
|
+
return this.screenText().join("\n").includes("READY");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
ejectDisk(driveNumber) {
|
|
231
|
+
this.io.fdc.ejectDrive(driveNumber);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Convenience for tests: run N emulated seconds. */
|
|
235
|
+
runSeconds(seconds) {
|
|
236
|
+
return this.runTStates(Math.round(seconds * this.clockHz));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Type text through the keyboard matrix at emulated speed ("turbo
|
|
241
|
+
* paste"): each key is held long enough for the ROM's scan/debounce,
|
|
242
|
+
* but the wall-clock cost is only the emulation time (~3 ms/char).
|
|
243
|
+
* '\n' presses ENTER. Returns the number of characters that had no
|
|
244
|
+
* matrix mapping and were skipped.
|
|
245
|
+
*
|
|
246
|
+
* After ENTER the ROM tokenizes and stores the line (cost grows with
|
|
247
|
+
* program size) before it scans the keyboard again — give it room or
|
|
248
|
+
* the first character of the next line gets eaten. The default suits
|
|
249
|
+
* the small built-in classics; pass a larger `enterTStates` for
|
|
250
|
+
* multi-KB listings (a 5.7 KB program needs ~1M).
|
|
251
|
+
*/
|
|
252
|
+
typeText(text, { enterTStates = 400000 } = {}) {
|
|
253
|
+
let skipped = 0;
|
|
254
|
+
let seq = 0;
|
|
255
|
+
for (const ch of text) {
|
|
256
|
+
const key = ch === "\n" ? "Enter" : ch;
|
|
257
|
+
const code = `typeText:${seq++}`;
|
|
258
|
+
if (!this.keyboard.keyDown(key, code)) {
|
|
259
|
+
skipped++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
this.runTStates(70000); // held across several scans + debounce
|
|
263
|
+
this.keyboard.keyUp(code);
|
|
264
|
+
this.runTStates(ch === "\n" ? enterTStates : 50000);
|
|
265
|
+
}
|
|
266
|
+
return skipped;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The 64x16 screen as strings, for tests and status displays.
|
|
271
|
+
* Control bytes 0x00-0x1F display as their 0x40-0x5F fold (Model III
|
|
272
|
+
* character generator); block-graphics bytes render as '.'.
|
|
273
|
+
*/
|
|
274
|
+
screenText() {
|
|
275
|
+
const rows = [];
|
|
276
|
+
for (let row = 0; row < 16; row++) {
|
|
277
|
+
let line = "";
|
|
278
|
+
for (let col = 0; col < 64; col++) {
|
|
279
|
+
let byte = this.memory.videoRam[row * 64 + col];
|
|
280
|
+
if (byte < 0x20) {
|
|
281
|
+
byte += 0x40;
|
|
282
|
+
}
|
|
283
|
+
line += byte >= 0x20 && byte < 0x80 ? String.fromCharCode(byte) : ".";
|
|
284
|
+
}
|
|
285
|
+
rows.push(line);
|
|
286
|
+
}
|
|
287
|
+
return rows;
|
|
288
|
+
}
|
|
289
|
+
}
|