@fugloy/emu 0.2.0 → 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/README.md +124 -8
- package/package.json +1 -1
- package/src/cpu/z80cpu.js +9 -2
- package/src/debug/debugger.js +20 -9
- package/src/debug/label-file.js +10 -4
- package/src/debug/state.js +69 -10
- package/src/devices/cassette.js +5 -1
- package/src/devices/disk-image.js +635 -248
- package/src/devices/fdc-wd1771.js +180 -0
- package/src/devices/fdc-wd1793.js +838 -239
- package/src/devices/interrupt-latch.js +76 -0
- package/src/devices/key-queue.js +22 -5
- package/src/devices/video.js +6 -3
- package/src/index.js +3 -2
- package/src/loaders/load.js +169 -5
- package/src/machine/drive-select.js +42 -0
- package/src/machine/io.js +238 -15
- package/src/machine/machine.js +53 -15
- package/src/machine/memory.js +107 -24
- package/src/machine/models.js +123 -0
- package/src/machine/rom-hashes.js +14 -7
- package/src/machine/trs80-system.js +90 -33
package/README.md
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
# @fugloy/emu
|
|
2
2
|
|
|
3
|
-
Headless, deterministic TRS-80 emulator core (Model III
|
|
3
|
+
Headless, deterministic TRS-80 emulator core (the Model III and, since emu phase 6, the Model I) in plain
|
|
4
4
|
JavaScript, for Node and the browser alike. Emu phase 0 brought in the core of
|
|
5
5
|
[cschweda/trs80-emulator](https://github.com/cschweda/trs80-emulator) (v1.6.0):
|
|
6
6
|
the Z80 CPU (`src/cpu/`), the Model III memory map, I/O ports, and system
|
|
7
7
|
(`src/machine/`), the keyboard matrix, cassette, WD1793 disk controller, disk
|
|
8
8
|
images, and printer (`src/devices/`), the `.CAS` and `.CMD` formats
|
|
9
|
-
(`src/loaders/`), and save states (`src/debug/`).
|
|
10
|
-
|
|
9
|
+
(`src/loaders/`), and save states (`src/debug/`). Emu phase 6 added the
|
|
10
|
+
Model I with its Expansion Interface (unreleased; the Model 4 is split off into
|
|
11
|
+
a plan of its own: a Model 4 ROM is now local, from the cpmarchives TRS32 ROM
|
|
12
|
+
set, whose `MODEL4P.ROM` is an emulator's stand-in rather than Tandy's 4P ROM;
|
|
13
|
+
the Model 4 phase is planned next, and its native mode still needs an LS-DOS 6
|
|
14
|
+
or TRSDOS 6 boot disk).
|
|
15
|
+
Phases 0–7 of the spec's eight are done (6 for the Model I, 7 for the Model III): the Z80 core passes ZEXDOC and
|
|
11
16
|
ZEXALL, the `Machine` below runs real programs, `m.debug` writes the label
|
|
12
17
|
and coverage file, and phase 4, the MCP server
|
|
13
18
|
[`@fugloy/mcp`](https://github.com/cschweda/fugloy/blob/main/packages/mcp/README.md),
|
|
14
|
-
drives it for an AI assistant. Version 0.2.0
|
|
15
|
-
(
|
|
19
|
+
drives it for an AI assistant. Version 0.3.0 (first released 2026-10-18; 0.2.0 is the version on npm until it is published)
|
|
20
|
+
(`npm i @fugloy/emu`; or use it from a clone for development; Node 22 or later, or any browser bundler: it has no
|
|
16
21
|
dependencies).
|
|
17
22
|
|
|
18
23
|
The renderer, the WebAudio driver, the turbo throttle, the web UI, and every
|
|
@@ -30,7 +35,7 @@ not implement.
|
|
|
30
35
|
## Using the machine
|
|
31
36
|
|
|
32
37
|
```js
|
|
33
|
-
import { readFileSync } from 'node:fs'
|
|
38
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
34
39
|
import { Machine, CPU_CLOCK_HZ } from '@fugloy/emu'
|
|
35
40
|
|
|
36
41
|
const m = new Machine({ roms: { level2: readFileSync('model3.rom') } }) // Model III, 48K; you supply the ROM
|
|
@@ -48,8 +53,68 @@ m.run({ cycles: 5 * second })
|
|
|
48
53
|
console.log(m.video.text().join('\n')) // the screen, 16 lines
|
|
49
54
|
```
|
|
50
55
|
|
|
56
|
+
A disk image (JV1, JV3, or DMK, detected from its content) goes in a drive
|
|
57
|
+
with `m.fdc.mount(drive, bytes)`, or through `m.load.dsk(bytes)`, which mounts
|
|
58
|
+
it in drive 0 and resets so the ROM boots from it:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
m.load.dsk(readFileSync('game.dmk')) // → { format: 'dsk', container: 'dmk', drive: 0, boot: true, blocks: [] }
|
|
62
|
+
m.fdc.drives // [{ drive: 0, container: 'dmk', tracks, sides, sectorsPerTrack, writeProtected }, null, null, null]
|
|
63
|
+
writeFileSync('saved.dmk', m.fdc.export(0)) // the disk as DMK bytes, with what the program wrote to it
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Writes stay in memory; `m.fdc.export(drive)` returns the disk as a DMK file,
|
|
67
|
+
whatever container it was mounted from (read JV1, JV3, and DMK; write DMK). A snapshot carries each disk's bytes and container, so a
|
|
68
|
+
disk the session formatted restores as it was. With a disk loaded through
|
|
69
|
+
`m.load.dsk` the label file lists no load blocks and `m.debug.labelFile()`
|
|
70
|
+
needs a `name`.
|
|
71
|
+
|
|
72
|
+
The WD1793 behind the drives follows the Western Digital FD179X data sheet in
|
|
73
|
+
T-states (spec §5): heads step at the programmed rate, sectors come round at
|
|
74
|
+
300 rpm, and bytes arrive one byte time apart, so a disk loads as slowly as it
|
|
75
|
+
does on a real drive, and a program that misses a byte sees Lost Data.
|
|
76
|
+
Give a disk session room: the ROM reaches the boot sector about a quarter
|
|
77
|
+
second after the reset, a loader reading a game takes seconds (Scarfman's
|
|
78
|
+
title is up about 8 s after the reset), and a sector that is not there costs
|
|
79
|
+
about a second. Check the screen between slices of `run` rather than in an
|
|
80
|
+
`until` callback, which reads it after every instruction.
|
|
81
|
+
|
|
51
82
|
`step`, `run`, the registers, memory, the keyboard (matrix typing and a ROM
|
|
52
|
-
keyboard hook), sound events, and the
|
|
83
|
+
keyboard hook), sound events, the loaders, and the disk drives are described
|
|
84
|
+
in spec §3.
|
|
85
|
+
|
|
86
|
+
### The Model I
|
|
87
|
+
|
|
88
|
+
`model: 1` builds a Model I (unreleased, emu phase 6): the 12K Level II ROM
|
|
89
|
+
(1.2 and 1.3 are recognised; a Model III ROM given for it is refused naming its
|
|
90
|
+
model), 4, 16, 32, or 48K of RAM (`ram`, default 48), and the Expansion
|
|
91
|
+
Interface (`expansion`, default `true`) with its 40 Hz clock in the latch at
|
|
92
|
+
`$37E0`, its WD1771 disk controller at `$37EC`–`$37EF`, and its printer.
|
|
93
|
+
`expansion: false` is a bare keyboard unit: no clock, no disk, `$37E0`–`$37EF`
|
|
94
|
+
read `$FF`. The stock video RAM has no bit 6, so lowercase reads back and shows
|
|
95
|
+
as punctuation and a program's mixed-case title in capitals; `lowercase: true`
|
|
96
|
+
fits the lowercase modification. Its clock is 1,774,080 Hz, `clockHz(1)`
|
|
97
|
+
(`CPU_CLOCK_HZ` stays the Model III's):
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
import { Machine } from '@fugloy/emu'
|
|
101
|
+
|
|
102
|
+
const m1 = new Machine({ model: 1, roms: { level2: readFileSync('level2-model1.rom') } })
|
|
103
|
+
const sec = m1.clockHz // clockHz(1): 1,774,080 T-states an emulated second
|
|
104
|
+
m1.run({ until: shows('SIZE?'), cycles: sec }) // MEMORY SIZE? (MEM SIZE? in Level II 1.3); no Cass?
|
|
105
|
+
m1.keyboard.type('\n')
|
|
106
|
+
m1.run({ until: shows('READY'), cycles: 5 * sec })
|
|
107
|
+
m1.load.cmd(readFileSync('elimin8a.cmd')) // Eliminator's Model I-only build
|
|
108
|
+
m1.run({ cycles: 2 * sec }) // "BY: WAYNE WESTMORELAND & TERRY GILMAN"
|
|
109
|
+
m1.load.dsk(readFileSync('rrgarddb.dmk')) // or a disk: the ROM reads track 0, sector 0 to $4200
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Type at 10 characters a second on the Model I (`m1.keyboard.type(text, {
|
|
113
|
+
cps: 10 })`): the Level II keyboard routine drops every other key at the
|
|
114
|
+
default 20. T-states, `m.sound` stamps included, are the model's own
|
|
115
|
+
(`m.clockHz`). A label file records its model, and `mergeLabelFiles` refuses
|
|
116
|
+
a Model I file with a Model III one. Cassettes play at 500 baud only. Real programs that run on it, and
|
|
117
|
+
what is still unverified against the Model I manual, are in spec §5.
|
|
53
118
|
|
|
54
119
|
## Debugging
|
|
55
120
|
|
|
@@ -95,13 +160,64 @@ process, on the laptop that built phase 3):
|
|
|
95
160
|
| Debugging off | about 42× real time (41.5–43.3× over three runs; phase 2 measured about 43×) |
|
|
96
161
|
| Coverage and a 10,000-deep trace | about 30× real time (29.5–30.7×) |
|
|
97
162
|
|
|
98
|
-
`test/integration/benchmark.test.js` asserts at least 20× for both
|
|
163
|
+
`test/integration/benchmark.test.js` asserts at least 20× for both, but only
|
|
164
|
+
when asked: `FUGLOY_BENCH=1 pnpm exec vitest run
|
|
165
|
+
packages/emu/test/integration/benchmark.test.js`, by itself on a quiet machine
|
|
166
|
+
(`./publish.sh check` runs it that way after the suite). In the full parallel
|
|
167
|
+
suite, beside the other ROM tests, it measured 12–15× against about 40× alone,
|
|
168
|
+
so it is skipped there. On the laptop that built phase 6 it measured 40× (off)
|
|
169
|
+
and 29× (debug); Sea Dragon on the Model I runs at about 48×.
|
|
99
170
|
|
|
100
171
|
- Spec: [`docs/fugloy-emu-spec.md`](https://github.com/cschweda/fugloy/blob/main/docs/fugloy-emu-spec.md) (phases 0–7 in §11)
|
|
101
172
|
- Label and coverage file shared with `mcp` and `disasm`: [`docs/label-format.md`](https://github.com/cschweda/fugloy/blob/main/docs/label-format.md)
|
|
102
173
|
- ROM images are never bundled or committed; `roms/` here is gitignored and
|
|
103
174
|
paths are supplied by the caller.
|
|
104
175
|
|
|
176
|
+
## Differential testing
|
|
177
|
+
|
|
178
|
+
Emu phase 7 runs a program here and in [trsjs](https://trsjs.48k.ca/)
|
|
179
|
+
(Peter Phillips's JavaScript conversion of George Phillips's trs80gp) in
|
|
180
|
+
lockstep, and compares the two after every instruction: PC, every register
|
|
181
|
+
(F bit by bit, R as 7 bits), IFF1/2, IM, the T-states, and every memory
|
|
182
|
+
write, with video RAM at checkpoints and at the end. Keys go into both
|
|
183
|
+
keyboard matrices at the same T-state. trsjs has no license, so it is never
|
|
184
|
+
committed or bundled: `pnpm trsjs:install` fetches it into the gitignored
|
|
185
|
+
`tools/trsjs/`, pinned by SHA-256, and the tests that need it are opt-in.
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
pnpm trsjs:install
|
|
189
|
+
FUGLOY_TRSJS=1 pnpm vitest run packages/emu/test/diff # about a minute: the harness tests and six game sessions
|
|
190
|
+
FUGLOY_TRSJS=1 FUGLOY_TRSJS_SWEEP=1 pnpm vitest run packages/emu/test/diff --silent=false # also every other .CMD in examples/ and fixtures/real/, logging each one's class counts and the sum
|
|
191
|
+
pnpm diff examples/sea-dragon/seadrg01.cmd --instructions 10000000 # one program, with a report
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`pnpm diff <program> [--keys <script.json>] [--instructions N] [--from-cycle C] [--strict | --strict-all]`
|
|
195
|
+
boots the Model III ROM from `fugloy.mcp.json`, loads the `.CMD` or `.BAS`,
|
|
196
|
+
and prints the count of each known difference and the first divergence, if
|
|
197
|
+
any, with ten instructions disassembled on each side. Exit status 0 means no
|
|
198
|
+
divergence, 1 a divergence, 2 a usage or setup error (an unexpected error,
|
|
199
|
+
such as the ROM not reaching READY or trsjs throwing, counts as one).
|
|
200
|
+
|
|
201
|
+
Where the two differ, trsjs is the inaccurate side, and the harness says so
|
|
202
|
+
explicitly. Some differences it corrects in the reference (a write to ROM or
|
|
203
|
+
the keyboard, a byte stored wider than 8 bits, PC running past `$FFFF`, a
|
|
204
|
+
repeating CPDR's T-states, a DD or FD prefix trsjs skips); the rest are known classes in
|
|
205
|
+
`test/diff/known.js`, each with the evidence for it, counted rather than
|
|
206
|
+
reported (flag bits 3 and 5; BIT's S and P/V; R on an interrupt and on
|
|
207
|
+
DDCB/FDCB; the HALT return address; carry after CPI/CPD/CPIR/CPDR; and three
|
|
208
|
+
interrupt timings). Everything else is a divergence. On the six sessions
|
|
209
|
+
(Sea Dragon, Eliminator c and e, Rear Guard 5 and 4, Armored Patrol, 10
|
|
210
|
+
million instructions each) there is none. The harness found two bugs on our
|
|
211
|
+
side, both now fixed: a halted CPU did not count R, and port `FF` did not
|
|
212
|
+
read port `EC`'s bits back. After those fixes, every remaining difference is
|
|
213
|
+
trsjs's. The harness cannot see a bug both machines share: where the two
|
|
214
|
+
agree, both may be wrong, so behaviour such as EI's one-instruction delay is
|
|
215
|
+
pinned by our own unit tests (against Zilog UM0080 and Young §5.5) as well as
|
|
216
|
+
compared. One is a judgment call rather than a trsjs error: port `FF` bit 6,
|
|
217
|
+
which the Model III Technical Reference Manual calls undefined; we read it as
|
|
218
|
+
1, as trsjs and trs80gp do. Details in spec §10 and §11 (phase 7). The Model I waits
|
|
219
|
+
for a Model I reference; trsjs is Model III only.
|
|
220
|
+
|
|
105
221
|
## Testing the Z80 core
|
|
106
222
|
|
|
107
223
|
ZEXDOC and ZEXALL, Frank Cringle's Z80 instruction exercisers, are GPL-2.0, so
|
package/package.json
CHANGED
package/src/cpu/z80cpu.js
CHANGED
|
@@ -243,6 +243,8 @@ export class Z80CPU {
|
|
|
243
243
|
executeInstruction() {
|
|
244
244
|
this.afterPrefix = false;
|
|
245
245
|
if (this.halted) {
|
|
246
|
+
// A halted Z80 keeps running NOP M1 cycles for refresh (Zilog UM0080), and R counts every M1
|
|
247
|
+
this.incrementR();
|
|
246
248
|
this.lastOpCycles = 4;
|
|
247
249
|
this.cycles += 4;
|
|
248
250
|
return 4;
|
|
@@ -315,10 +317,14 @@ export class Z80CPU {
|
|
|
315
317
|
}
|
|
316
318
|
|
|
317
319
|
/**
|
|
318
|
-
* Non-maskable interrupt:
|
|
319
|
-
*
|
|
320
|
+
* Non-maskable interrupt: saves IFF1 into IFF2 so RETN can restore it,
|
|
321
|
+
* jumps to 0x0066. Not taken right after a lone DD or FD prefix (one the
|
|
322
|
+
* next prefix or ED made a no-op), as a maskable interrupt is not (Young
|
|
323
|
+
* §3.6); the caller keeps the request pending.
|
|
324
|
+
* @returns {boolean} true when the NMI was taken
|
|
320
325
|
*/
|
|
321
326
|
nmi() {
|
|
327
|
+
if (this.afterPrefix) return false;
|
|
322
328
|
this.halted = false;
|
|
323
329
|
this.IFF2 = this.IFF1;
|
|
324
330
|
this.IFF1 = false;
|
|
@@ -332,6 +338,7 @@ export class Z80CPU {
|
|
|
332
338
|
this.registers.PC = 0x0066;
|
|
333
339
|
this.wz = 0x0066; // MEMPTR, as for CALL
|
|
334
340
|
this.cycles += 11;
|
|
341
|
+
return true;
|
|
335
342
|
}
|
|
336
343
|
|
|
337
344
|
// Decode and execute opcode
|
package/src/debug/debugger.js
CHANGED
|
@@ -84,7 +84,7 @@ export class Debugger {
|
|
|
84
84
|
#machine;
|
|
85
85
|
/** @type {any} The Z80CPU whose accessors are swapped. */
|
|
86
86
|
#cpu;
|
|
87
|
-
/** @type {{ readMemory: (a: number) => number, writeMemory: (a: number, v: number) => void, readPort: (p: number) => number, writePort: (p: number, v: number) => void }} The CPU's own accessors, restored when nothing listens. */
|
|
87
|
+
/** @type {{ readMemory: (a: number) => number, writeMemory: (a: number, v: number) => void, readPort: (p: number) => number, writePort: (p: number, v: number) => void, peek: (a: number) => number }} The CPU's own accessors, restored when nothing listens, and `peek`, a read with no device side effect (memory.peekByte) for the debugger's own looks at memory. */
|
|
88
88
|
#plain;
|
|
89
89
|
/** @type {Map<number, BreakEntry[]>} */
|
|
90
90
|
#breaks = new Map();
|
|
@@ -159,7 +159,18 @@ export class Debugger {
|
|
|
159
159
|
this.#machine = machine;
|
|
160
160
|
const cpu = machine.system.cpu;
|
|
161
161
|
this.#cpu = cpu;
|
|
162
|
-
|
|
162
|
+
const memory = machine.system.memory;
|
|
163
|
+
// The debugger's own reads (an old value, the look-ahead, the trace, the label file) peek: a
|
|
164
|
+
// CPU read of the Model I's $37E0 clears the latch's clock bit, and one of $37EC drops the
|
|
165
|
+
// WD1771's INTRQ. With no Expansion Interface (every Model III) a read has no side effect, and
|
|
166
|
+
// the CPU's own accessor serves.
|
|
167
|
+
this.#plain = {
|
|
168
|
+
readMemory: cpu.readMemory,
|
|
169
|
+
writeMemory: cpu.writeMemory,
|
|
170
|
+
readPort: cpu.readPort,
|
|
171
|
+
writePort: cpu.writePort,
|
|
172
|
+
peek: memory.expansion === null ? cpu.readMemory : (/** @type {number} */ a) => memory.peekByte(a),
|
|
173
|
+
};
|
|
163
174
|
const dbg = this;
|
|
164
175
|
/** @param {string} name @param {unknown} f */
|
|
165
176
|
const check = (name, f) => {
|
|
@@ -308,7 +319,7 @@ export class Debugger {
|
|
|
308
319
|
this.#loaded = { manifest: JSON.parse(JSON.stringify(manifest)), sha256: sha256Hex(bytes) };
|
|
309
320
|
// A copy of what landed, so the label file can tell which loaded bytes changed since (a
|
|
310
321
|
// block still `predicted` has not landed). Read after the loader, so a later block wins.
|
|
311
|
-
const read = this.#plain.
|
|
322
|
+
const read = this.#plain.peek;
|
|
312
323
|
this.#placed = manifest.blocks
|
|
313
324
|
.filter((/** @type {any} */ b) => !b.predicted)
|
|
314
325
|
.map((/** @type {any} */ b) => {
|
|
@@ -331,7 +342,7 @@ export class Debugger {
|
|
|
331
342
|
*/
|
|
332
343
|
labelFile({ name, started, input } = {}) {
|
|
333
344
|
const bits = this.#bits.slice();
|
|
334
|
-
const read = this.#plain.
|
|
345
|
+
const read = this.#plain.peek;
|
|
335
346
|
for (const { addr, bytes } of this.#placed) {
|
|
336
347
|
for (let i = 0; i < bytes.length; i++) {
|
|
337
348
|
const a = (addr + i) & 0xffff;
|
|
@@ -347,6 +358,7 @@ export class Debugger {
|
|
|
347
358
|
bits,
|
|
348
359
|
callerMap: this.callerMap(),
|
|
349
360
|
cycles: this.#cpu.cycles,
|
|
361
|
+
model: this.#machine.model,
|
|
350
362
|
});
|
|
351
363
|
}
|
|
352
364
|
|
|
@@ -525,9 +537,8 @@ export class Debugger {
|
|
|
525
537
|
this.#len = 0;
|
|
526
538
|
return;
|
|
527
539
|
}
|
|
528
|
-
// The look-ahead
|
|
529
|
-
|
|
530
|
-
const info = instructionInfo(this.#plain.readMemory, pc);
|
|
540
|
+
// The look-ahead peeks, so it clears no device latch (the Model I's $37E0 and $37EC).
|
|
541
|
+
const info = instructionInfo(this.#plain.peek, pc);
|
|
531
542
|
const len = info.len;
|
|
532
543
|
this.#len = len;
|
|
533
544
|
if (this.#covOn) {
|
|
@@ -577,7 +588,7 @@ export class Debugger {
|
|
|
577
588
|
/** Open the next trace entry for the instruction at `pc`: its bytes and registers before it runs; `afterInstruction` adds its T-states. */
|
|
578
589
|
#traceStart(/** @type {number} */ pc) {
|
|
579
590
|
const i = this.#tHead;
|
|
580
|
-
const read = this.#plain.
|
|
591
|
+
const read = this.#plain.peek;
|
|
581
592
|
const len = instructionInfo(read, pc).len;
|
|
582
593
|
this.#tPcs[i] = pc;
|
|
583
594
|
this.#tLens[i] = len;
|
|
@@ -630,7 +641,7 @@ export class Debugger {
|
|
|
630
641
|
addr &= 0xffff;
|
|
631
642
|
value &= 0xff;
|
|
632
643
|
const watched = (this.#watchMask[addr] & 2) !== 0;
|
|
633
|
-
const prev = watched || this.#onMemWrite !== null ? this.#plain.
|
|
644
|
+
const prev = watched || this.#onMemWrite !== null ? this.#plain.peek(addr) : 0;
|
|
634
645
|
this.#plain.writeMemory(addr, value);
|
|
635
646
|
if (this.#marking) this.#bits[addr] |= WRITE;
|
|
636
647
|
if (watched) this.#hit('write', addr, value, prev);
|
package/src/debug/label-file.js
CHANGED
|
@@ -97,10 +97,11 @@ export function decodeRle(text) {
|
|
|
97
97
|
* Write the §8 object from a debugger session.
|
|
98
98
|
* @param {{ name?: string, started?: string, input?: string,
|
|
99
99
|
* loaded: { manifest: any, sha256: string } | null, userLabels: Label[], bits: Uint8Array,
|
|
100
|
-
* callerMap: Map<number, CallerEdge[]>, cycles: number }} session
|
|
100
|
+
* callerMap: Map<number, CallerEdge[]>, cycles: number, model?: 1 | 3 }} session
|
|
101
|
+
* `model` is the machine's (default 3, the Model III), recorded as `program.model`
|
|
101
102
|
* @returns {LabelFile}
|
|
102
103
|
*/
|
|
103
|
-
export function writeLabelFile({ name, started, input, loaded, userLabels, bits, callerMap, cycles }) {
|
|
104
|
+
export function writeLabelFile({ name, started, input, loaded, userLabels, bits, callerMap, cycles, model = 3 }) {
|
|
104
105
|
if (name !== undefined && (typeof name !== 'string' || name === '')) throw new Error(`labelFile: name must be a non-empty string, not ${String(name)}`);
|
|
105
106
|
if (started !== undefined && typeof started !== 'string') throw new Error(`labelFile: started must be a string (the core reads no clock; pass the time yourself), not ${String(started)}`);
|
|
106
107
|
if (input !== undefined && typeof input !== 'string') throw new Error(`labelFile: input must be a string, not ${String(input)}`);
|
|
@@ -111,7 +112,7 @@ export function writeLabelFile({ name, started, input, loaded, userLabels, bits,
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/** @type {LabelFile['program']} */
|
|
114
|
-
const program = loaded === null ? { name: programName, model
|
|
115
|
+
const program = loaded === null ? { name: programName, model } : { name: programName, sha256: loaded.sha256, model };
|
|
115
116
|
/** @type {LabelFile['load']} */
|
|
116
117
|
const load = [];
|
|
117
118
|
if (manifest !== undefined) {
|
|
@@ -321,7 +322,8 @@ function labelKey(l) {
|
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
/**
|
|
324
|
-
* Merge two label files by the §8 rule
|
|
325
|
+
* Merge two label files by the §8 rule (refused when both name a `program.sha256` and they differ,
|
|
326
|
+
* or their `program.model`s differ): coverage unioned per key; labels by address with `user`
|
|
325
327
|
* over `observed` over `disasm` over `loader` (`b` wins a tie); `load` unioned without duplicates;
|
|
326
328
|
* `entry` is `a`'s unless null; callers unioned with counts summed per `(from, kind)`; sessions
|
|
327
329
|
* appended, `a`'s first. Unknown top-level keys are kept, `a`'s winning.
|
|
@@ -335,6 +337,10 @@ export function mergeLabelFiles(a, b) {
|
|
|
335
337
|
const sa = fa.program.sha256;
|
|
336
338
|
const sb = fb.program.sha256;
|
|
337
339
|
if (sa !== undefined && sb !== undefined && sa !== sb) throw new Error(`mergeLabelFiles: the files are for different programs (sha256 ${sa} and ${sb})`);
|
|
340
|
+
// Two models' files would union the coverage of two different ROMs at the same addresses.
|
|
341
|
+
const ma = fa.program.model;
|
|
342
|
+
const mb = fb.program.model;
|
|
343
|
+
if (ma !== mb) throw new Error(`mergeLabelFiles: the files are for different models (${ma} and ${mb}), whose ROMs and hardware differ`);
|
|
338
344
|
|
|
339
345
|
const { name, sha256: _s, model, ...programRest } = fa.program;
|
|
340
346
|
const sha256 = sa ?? sb;
|
package/src/debug/state.js
CHANGED
|
@@ -11,8 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { DiskImage } from "../devices/disk-image.js";
|
|
14
|
+
import { MODELS } from "../machine/models.js";
|
|
15
|
+
|
|
16
|
+
/** Model names by save-state machine id ("trs80-model1": "Model I"). */
|
|
17
|
+
const MODELS_BY_STATE = Object.fromEntries(Object.values(MODELS).map((d) => [d.stateMachine, d.name]));
|
|
14
18
|
|
|
15
19
|
export const STATE_VERSION = 1;
|
|
20
|
+
/** The Model III's machine id; a state carries its system's model's (`model.stateMachine`, models.js). */
|
|
16
21
|
export const STATE_MACHINE = "trs80-model3";
|
|
17
22
|
|
|
18
23
|
// Base64 without platform APIs, so the core runs anywhere (spec §2.1).
|
|
@@ -75,8 +80,11 @@ const FDC_SCALARS = [
|
|
|
75
80
|
"status",
|
|
76
81
|
"intrq",
|
|
77
82
|
"commandType",
|
|
78
|
-
"
|
|
79
|
-
"
|
|
83
|
+
"command",
|
|
84
|
+
"stepDir",
|
|
85
|
+
"headLoaded",
|
|
86
|
+
"idlePulses",
|
|
87
|
+
"intrqCond",
|
|
80
88
|
"writeDeleted",
|
|
81
89
|
"mfm",
|
|
82
90
|
"indexClock",
|
|
@@ -94,7 +102,7 @@ export function serializeState(system) {
|
|
|
94
102
|
|
|
95
103
|
const state = {
|
|
96
104
|
version: STATE_VERSION,
|
|
97
|
-
machine: STATE_MACHINE,
|
|
105
|
+
machine: system.model?.stateMachine ?? STATE_MACHINE,
|
|
98
106
|
cpu: { registers },
|
|
99
107
|
memory: {
|
|
100
108
|
ram: toBase64(system.memory.ram),
|
|
@@ -123,7 +131,14 @@ export function serializeState(system) {
|
|
|
123
131
|
printer: { bytes: [...io.printer.bytes] },
|
|
124
132
|
disks: [],
|
|
125
133
|
nextRtcAt: system.nextRtcAt,
|
|
134
|
+
// The NMI edge latch: true while the INTRQ NMI already fired and its line is still up, so a
|
|
135
|
+
// restore neither fires it again nor loses one still pending.
|
|
136
|
+
nmiWasPending: system._nmiWasPending,
|
|
126
137
|
};
|
|
138
|
+
// The Model I's Expansion Interface latch at $37E0 (absent from Model III states, which stay as
|
|
139
|
+
// they were, and from a Model I's without the interface) and its lowercase modification
|
|
140
|
+
if (io.latch) state.latch = io.latch.bits;
|
|
141
|
+
if (system.model?.id === 1) state.lowercase = !!system.lowercase;
|
|
127
142
|
for (const key of CPU_SCALARS) state.cpu[key] = cpu[key];
|
|
128
143
|
for (const key of IO_SCALARS) state.io[key] = io[key];
|
|
129
144
|
for (const key of FDC_SCALARS) state.fdc[key] = fdc[key];
|
|
@@ -134,6 +149,11 @@ export function serializeState(system) {
|
|
|
134
149
|
drive,
|
|
135
150
|
name: disk.name,
|
|
136
151
|
bytes: toBase64(disk.bytes),
|
|
152
|
+
// The container and a JV1's directory track, so a restore parses the bytes as what they
|
|
153
|
+
// are rather than detecting again (a JV3 the session formatted may hold too few sectors for
|
|
154
|
+
// detection, and a JV1's track 0 may have been rewritten since the mount read its byte 2).
|
|
155
|
+
format: disk.format,
|
|
156
|
+
dirTrack: disk.format === "jv1" ? disk.dirTrack : undefined,
|
|
137
157
|
sectorBase: disk.sectorBase,
|
|
138
158
|
writeProtected: disk.writeProtected,
|
|
139
159
|
});
|
|
@@ -147,12 +167,37 @@ export function serializeState(system) {
|
|
|
147
167
|
* Throws on version/machine mismatch rather than resuming garbage.
|
|
148
168
|
*/
|
|
149
169
|
export function restoreState(system, state) {
|
|
150
|
-
|
|
151
|
-
|
|
170
|
+
const machine = system.model?.stateMachine ?? STATE_MACHINE;
|
|
171
|
+
if (!state || state.machine !== machine) {
|
|
172
|
+
const name = system.model?.name ?? "Model III";
|
|
173
|
+
const from = typeof state?.machine === "string" && Object.hasOwn(MODELS_BY_STATE, state.machine) ? MODELS_BY_STATE[state.machine] : undefined;
|
|
174
|
+
// A state of a model the emulator builds says which; anything else keeps the old sentence.
|
|
175
|
+
if (from !== undefined && name !== "Model III") {
|
|
176
|
+
throw new Error(`This is a ${from} save state; the machine is a ${name}, and a state restores only into the model that saved it.`);
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Not a TRS-80 ${name} save state`);
|
|
152
179
|
}
|
|
153
180
|
if (state.version !== STATE_VERSION) {
|
|
154
181
|
throw new Error(`Unsupported save-state version ${state.version}`);
|
|
155
182
|
}
|
|
183
|
+
const ram = fromBase64(state.memory.ram);
|
|
184
|
+
if (ram.length !== system.memory.ram.length) {
|
|
185
|
+
throw new Error(`This ${system.model?.name ?? "Model III"} save state has ${ram.length / 1024}K of RAM; the machine has ${system.memory.ram.length / 1024}K, and a state restores only into a machine with the same RAM`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A Model I state restores only into a Model I fitted the same way: the Expansion Interface
|
|
189
|
+
// (its latch, its disk controller) and the lowercase modification (what video RAM holds).
|
|
190
|
+
if (system.model?.id === 1) {
|
|
191
|
+
const saved = Object.hasOwn(state, "latch");
|
|
192
|
+
const fitted = system.io.latch !== null;
|
|
193
|
+
if (saved !== fitted) {
|
|
194
|
+
throw new Error(`This Model I save state was saved ${saved ? "with" : "without"} the Expansion Interface; the machine has ${fitted ? "one" : "none"}, and a state restores only into a machine fitted the same way (expansion: ${saved}).`);
|
|
195
|
+
}
|
|
196
|
+
const lowercase = state.lowercase ?? false;
|
|
197
|
+
if (lowercase !== !!system.lowercase) {
|
|
198
|
+
throw new Error(`This Model I save state was saved ${lowercase ? "with" : "without"} the lowercase modification; the machine has ${system.lowercase ? "it" : "none"}, and a state restores only into a machine fitted the same way (lowercase: ${lowercase}).`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
156
201
|
|
|
157
202
|
const cpu = system.cpu;
|
|
158
203
|
const io = system.io;
|
|
@@ -166,13 +211,19 @@ export function restoreState(system, state) {
|
|
|
166
211
|
cpu.afterPrefix ??= false; // likewise
|
|
167
212
|
for (const key of IO_SCALARS) io[key] = state.io[key];
|
|
168
213
|
|
|
169
|
-
system.memory.ram.set(
|
|
214
|
+
system.memory.ram.set(ram);
|
|
170
215
|
system.memory.videoRam.set(fromBase64(state.memory.videoRam));
|
|
171
216
|
system.memory.videoDirty = true;
|
|
172
217
|
|
|
173
218
|
for (const key of FDC_SCALARS) fdc[key] = state.fdc[key];
|
|
174
219
|
fdc.writeDeleted = !!fdc.writeDeleted; // absent in version-1 saves from before DAM support
|
|
175
220
|
fdc.mfm = state.fdc.mfm ?? true; // absent before WRITE TRACK support
|
|
221
|
+
// Absent before the emu phase 5 datasheet model: the defaults a reset leaves.
|
|
222
|
+
fdc.command ??= 0;
|
|
223
|
+
fdc.stepDir ??= 1;
|
|
224
|
+
fdc.headLoaded ??= false;
|
|
225
|
+
fdc.idlePulses ??= 0;
|
|
226
|
+
fdc.intrqCond ??= 0;
|
|
176
227
|
fdc.physicalTrack = [...state.fdc.physicalTrack];
|
|
177
228
|
fdc.pending = state.fdc.pending ? { ...state.fdc.pending } : null;
|
|
178
229
|
fdc.buffer = state.fdc.buffer ? fromBase64(state.fdc.buffer) : null;
|
|
@@ -197,15 +248,23 @@ export function restoreState(system, state) {
|
|
|
197
248
|
|
|
198
249
|
fdc.drives = [null, null, null, null];
|
|
199
250
|
for (const entry of state.disks) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
251
|
+
// Saves from before emu phase 5's final fixes carry no format: detected, as they were made.
|
|
252
|
+
const disk = new DiskImage(fromBase64(entry.bytes), entry.name, {
|
|
253
|
+
format: entry.format ?? undefined,
|
|
254
|
+
dirTrack: entry.dirTrack ?? undefined,
|
|
255
|
+
});
|
|
256
|
+
// Carry over flags the constructor can't know: write protection set at mount, and a JV1's
|
|
257
|
+
// sector base (fixed by its geometry since emu phase 5; older saves may hold null, meaning the
|
|
258
|
+
// geometry's, or a base learned by the fallback the reader then had).
|
|
259
|
+
if (entry.sectorBase != null) disk.sectorBase = entry.sectorBase;
|
|
203
260
|
disk.writeProtected = entry.writeProtected;
|
|
204
261
|
fdc.drives[entry.drive & 3] = disk;
|
|
205
262
|
}
|
|
206
263
|
|
|
264
|
+
if (io.latch) io.latch.bits = state.latch ?? 0;
|
|
207
265
|
system.nextRtcAt = state.nextRtcAt;
|
|
208
|
-
system._nmiWasPending = false;
|
|
266
|
+
system._nmiWasPending = state.nmiWasPending ?? false; // absent before emu phase 5
|
|
267
|
+
system._fdcAhead = 0;
|
|
209
268
|
system.keyboard.reset(); // held keys don't survive a restore
|
|
210
269
|
io.soundLog = [];
|
|
211
270
|
io.onSoundReset?.(); // the Machine's raw sound ring belongs to the old timeline
|
package/src/devices/cassette.js
CHANGED
|
@@ -43,6 +43,10 @@ export class CassetteSystem {
|
|
|
43
43
|
this.recordLog = [];
|
|
44
44
|
this.RECORD_LIMIT = 400000;
|
|
45
45
|
this.restoredRecording = null; // decoded bytes carried in by a save state
|
|
46
|
+
// The pulse codec (cassette-tape.js) counts Model III T-states; a machine
|
|
47
|
+
// with another clock (the Model I) sets this to the Model III's clock over
|
|
48
|
+
// its own, so a recording decodes at the times it took in real time.
|
|
49
|
+
this.timeScale = 1;
|
|
46
50
|
|
|
47
51
|
this.BUFFER_ADDRESS = 0x4300;
|
|
48
52
|
this.BUFFER_SIZE = 256;
|
|
@@ -130,7 +134,7 @@ export class CassetteSystem {
|
|
|
130
134
|
*/
|
|
131
135
|
recordedCas() {
|
|
132
136
|
if (this.recordLog.length) {
|
|
133
|
-
return pulsesToBytes(this.recordLog.map((r) => r.t));
|
|
137
|
+
return pulsesToBytes(this.recordLog.map((r) => r.t * this.timeScale));
|
|
134
138
|
}
|
|
135
139
|
if (this.restoredRecording) {
|
|
136
140
|
return { bytes: this.restoredRecording, baud: null };
|