@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christopher Schweda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,27 @@
1
+ # Notice
2
+
3
+ The files under `src/`, all but `src/index.js`, come from [cschweda/trs80-emulator](https://github.com/cschweda/trs80-emulator) at commit 11612849c7e4dbbe76a45b38116486223fb05a9a (v1.6.0), moved into this layout in emu phase 0, under this license:
4
+
5
+ ```text
6
+ MIT License
7
+
8
+ Copyright (c) 2024 TRS-80 Model III Emulator Contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ ```
package/README.md CHANGED
@@ -1,3 +1,236 @@
1
- # Temporary Holding Version
1
+ # @fugloy/emu
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Headless, deterministic TRS-80 emulator core (the Model III and, since emu phase 6, the Model I) in plain
4
+ JavaScript, for Node and the browser alike. Emu phase 0 brought in the core of
5
+ [cschweda/trs80-emulator](https://github.com/cschweda/trs80-emulator) (v1.6.0):
6
+ the Z80 CPU (`src/cpu/`), the Model III memory map, I/O ports, and system
7
+ (`src/machine/`), the keyboard matrix, cassette, WD1793 disk controller, disk
8
+ images, and printer (`src/devices/`), the `.CAS` and `.CMD` formats
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
16
+ ZEXALL, the `Machine` below runs real programs, `m.debug` writes the label
17
+ and coverage file, and phase 4, the MCP server
18
+ [`@fugloy/mcp`](https://github.com/cschweda/fugloy/blob/main/packages/mcp/README.md),
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
21
+ dependencies).
22
+
23
+ The renderer, the WebAudio driver, the turbo throttle, the web UI, and every
24
+ ROM, disk, and program file stayed upstream: rendering and sound belong to the
25
+ workbench, and the caller supplies ROM bytes. Provenance and license:
26
+ [`NOTICE.md`](https://github.com/cschweda/fugloy/blob/main/packages/emu/NOTICE.md).
27
+
28
+ The core never writes to the console. Pass a logger to see what it reports,
29
+ such as an unimplemented opcode or a tape load:
30
+ `new TRS80System({ romData, log: (level, text) => console[level === 'info' ? 'log' : level](text) })`.
31
+ Without one it stays silent, unlike upstream, which printed warnings;
32
+ `system.cpu.unimplementedOpcodes` still records every opcode it hit and does
33
+ not implement.
34
+
35
+ ## Using the machine
36
+
37
+ ```js
38
+ import { readFileSync, writeFileSync } from 'node:fs'
39
+ import { Machine, CPU_CLOCK_HZ } from '@fugloy/emu'
40
+
41
+ const m = new Machine({ roms: { level2: readFileSync('model3.rom') } }) // Model III, 48K; you supply the ROM
42
+ const shows = (text) => (mm) => mm.video.text().join('\n').includes(text)
43
+ const second = CPU_CLOCK_HZ // T-states per emulated second
44
+
45
+ m.run({ until: shows('Cass?'), cycles: 5 * second }) // boot; stops at the prompt, or after 5 s
46
+ m.keyboard.type('\n') // ENTER: 1500 baud
47
+ m.run({ until: shows('Memory Size?'), cycles: 5 * second })
48
+ m.keyboard.type('\n') // ENTER at Memory Size?
49
+ m.run({ until: shows('READY'), cycles: 5 * second })
50
+
51
+ m.load.cmd(readFileSync('seadrg01.cmd')) // PC at the program's entry
52
+ m.run({ cycles: 5 * second })
53
+ console.log(m.video.text().join('\n')) // the screen, 16 lines
54
+ ```
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
+
82
+ `step`, `run`, the registers, memory, the keyboard (matrix typing and a ROM
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.
118
+
119
+ ## Debugging
120
+
121
+ `m.debug` stops on breakpoints and watches, logs memory hits, records which
122
+ bytes ran, were read, written, jumped to, and called, keeps a bounded trace,
123
+ takes snapshots, and writes the label and coverage file that `@fugloy/disasm`
124
+ reads (`fugloy seadrg01.cmd --labels seadrg01.labels.json`). Continuing the
125
+ example above, just after `m.load.cmd`:
126
+
127
+ ```js
128
+ import { writeFileSync } from 'node:fs'
129
+
130
+ m.debug.break(0x497f, { once: true }) // Sea Dragon's stub copies the game down, then jumps here
131
+ m.run({ cycles: second }) // → { reason: 'break', id: 1, cycles }
132
+ m.debug.coverage.enable() // from here on: exec, operand, read, write, jump, call
133
+ m.debug.trace.enable({ depth: 10_000 }) // the last 10,000 instructions
134
+ const top = m.debug.watch(0x3c00, 64, { write: true }) // log every write to the screen's top line
135
+ m.run({ cycles: 5 * second })
136
+ m.debug.hits(top) // [{ id: 2, kind: 'write', cycle, pc, addr, value, prev }, …]
137
+ m.debug.callers(0x6212) // [{ from: 18974, kind: 'call', count: 80 }, …]
138
+ m.debug.label(0x6212, 'TONE') // a user label: the listing names the routine TONE
139
+ writeFileSync('seadrg01.labels.json', JSON.stringify(m.debug.labelFile({ name: 'seadrg01.cmd' }), null, 2))
140
+ ```
141
+
142
+ Coverage can start at the load: the label file's `write` field marks every
143
+ byte a self-relocating program's copy writes, and every loaded byte that
144
+ differs from what the load placed, so the disassembler neither mistakes moved
145
+ code for data nor applies the moved code's coverage to the file's bytes (a
146
+ relocate hint says where it runs).
147
+ Everything on `m.debug` is described in spec §3 and §7, the file in §8 and
148
+ [`docs/label-format.md`](https://github.com/cschweda/fugloy/blob/main/docs/label-format.md).
149
+ `examples/sea-dragon/seadrg01.labels.json` is the file of the scripted session
150
+ in `test/integration/sea-dragon-session.js`; `node
151
+ packages/emu/scripts/sea-dragon-labels.mjs` rewrites it (it needs the ROM).
152
+
153
+ Debugging off costs nothing measurable, and the debugger keeps the §4 target.
154
+ On Sea Dragon, 20 emulated seconds of its title screen
155
+ (`node packages/emu/scripts/benchmark.mjs off|debug`, each mode in its own
156
+ process, on the laptop that built phase 3):
157
+
158
+ | Mode | Speed |
159
+ |---|---|
160
+ | Debugging off | about 42× real time (41.5–43.3× over three runs; phase 2 measured about 43×) |
161
+ | Coverage and a 10,000-deep trace | about 30× real time (29.5–30.7×) |
162
+
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×.
170
+
171
+ - Spec: [`docs/fugloy-emu-spec.md`](https://github.com/cschweda/fugloy/blob/main/docs/fugloy-emu-spec.md) (phases 0–7 in §11)
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)
173
+ - ROM images are never bundled or committed; `roms/` here is gitignored and
174
+ paths are supplied by the caller.
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
+
221
+ ## Testing the Z80 core
222
+
223
+ ZEXDOC and ZEXALL, Frank Cringle's Z80 instruction exercisers, are GPL-2.0, so
224
+ they are never committed. `pnpm zex:install` downloads them into the
225
+ gitignored `tools/zex/`, pinned to one commit of
226
+ [agn453/ZEXALL](https://github.com/agn453/ZEXALL) and checked by SHA-256. Then:
227
+
228
+ ```sh
229
+ FUGLOY_ZEX=1 pnpm vitest run packages/emu/test/exerciser # about 5 minutes, one test per group
230
+ FUGLOY_ZEX=1 pnpm vitest run packages/emu/test/exerciser -t "daa,cpl" # one group
231
+ ```
232
+
233
+ They run on `test/cpm.js`, a CP/M stand-in: the program at `0100H`, BDOS
234
+ functions 2 and 9 at `0005H`, and a jump to `0000H` to end.
235
+ `test/unit/cpu-timing.test.js` checks every opcode's T-states against
236
+ `@fugloy/z80`.
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@fugloy/emu",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "description": "Headless, deterministic TRS-80 emulator core, Model III today (see docs/fugloy-emu-spec.md)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "NOTICE.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "devDependencies": {
18
+ "@fugloy/disasm": "1.2.0",
19
+ "@fugloy/z80": "1.2.0"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/cschweda/fugloy.git",
24
+ "directory": "packages/emu"
25
+ },
26
+ "homepage": "https://github.com/cschweda/fugloy#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/cschweda/fugloy/issues"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
6
33
  }