@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 CHANGED
@@ -1,18 +1,23 @@
1
1
  # @fugloy/emu
2
2
 
3
- Headless, deterministic TRS-80 emulator core (Model III; Model I and 4 to come) in plain
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/`). Model III only for now.
10
- Phases 0–4 of the spec's eight are done: the Z80 core passes ZEXDOC and
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, its first npm release pending
15
- (once published, `npm i @fugloy/emu`; until then, use it from a clone; Node 22 or later, or any browser bundler: it has no
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 three loaders are described in spec §3.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fugloy/emu",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Headless, deterministic TRS-80 emulator core, Model III today (see docs/fugloy-emu-spec.md)",
5
5
  "license": "MIT",
6
6
  "type": "module",
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: always taken, saves IFF1 into IFF2 so RETN
319
- * can restore it, jumps to 0x0066.
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
@@ -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
- this.#plain = { readMemory: cpu.readMemory, writeMemory: cpu.writeMemory, readPort: cpu.readPort, writePort: cpu.writePort };
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.readMemory;
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.readMemory;
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 assumes reads have no side effects: true on the Model III; the Model I's $37E0
529
- // interrupt latch (phase 6) clears on read and will need a side-effect-free peek.
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.readMemory;
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.readMemory(addr) : 0;
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);
@@ -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: 3 } : { name: programName, sha256: loaded.sha256, model: 3 };
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: coverage unioned per key; labels by address with `user`
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;
@@ -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
- "bufferPos",
79
- "writing",
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
- if (!state || state.machine !== STATE_MACHINE) {
151
- throw new Error("Not a TRS-80 Model III save state");
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(fromBase64(state.memory.ram));
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
- const disk = new DiskImage(fromBase64(entry.bytes), entry.name);
201
- // Carry over learned/derived flags the constructor can't know
202
- disk.sectorBase = entry.sectorBase;
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
@@ -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 };