@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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The Machine's .CMD and .CAS loaders and its cassette deck controls. Each loader returns a
3
+ * manifest: where the bytes land and why (spec §6).
4
+ *
5
+ * A .CAS goes in one of two ways. Via 'rom' (the default) the tape is put in the cassette deck as
6
+ * pulses at the chosen baud, and the ROM's own loader reads it when the caller types CLOAD or
7
+ * SYSTEM; nothing is written until then, so every manifest block carries `predicted: true`. Via
8
+ * 'fast' the blocks are written at once (fastLoadBasic, fastLoadSystem).
9
+ */
10
+ import { parseCmd, fastLoadCmd } from './cmd-format.js';
11
+ import { parseCas, fastLoadBasic, fastLoadSystem } from './cas-format.js';
12
+ import { casToPulses } from '../devices/cassette-tape.js';
13
+ import { CPU_CLOCK_HZ } from '../machine/trs80-system.js';
14
+ import { DiskImage, JV1_MODEL1_SD_TRACKS } from '../devices/disk-image.js';
15
+
16
+ /**
17
+ * @typedef {{ addr: number, len: number, why: string, predicted?: true }} ManifestBlock
18
+ * `predicted` marks a block that has not landed yet (via 'rom', before the ROM reads the tape)
19
+ * @typedef {{ format: 'cmd', name: string, entry: number, blocks: ManifestBlock[] }} CmdManifest
20
+ * @typedef {{ format: 'cas', kind: 'basic' | 'system', name: string, entry?: number, blocks: ManifestBlock[],
21
+ * baud: 500 | 1500 | null, via: 'rom' | 'fast' }} CasManifest
22
+ * @typedef {{ drive: number, container: 'jv1' | 'jv3' | 'dmk', tracks: number, sides: number,
23
+ * sectorsPerTrack?: number, writeProtected: boolean }} DriveInfo
24
+ * `sectorsPerTrack` is present only when every track side holds the same number of sectors
25
+ * @typedef {{ format: 'dsk', container: 'jv1' | 'jv3' | 'dmk', drive: number, boot: boolean, blocks: ManifestBlock[] }} DskManifest
26
+ * `blocks` is empty: mounting writes nothing to memory (what the ROM or a DOS reads lands later)
27
+ */
28
+
29
+ const TXTTAB_PTR = 0x40a4;
30
+
31
+ /**
32
+ * @param {unknown} baud
33
+ * @param {import('../machine/machine.js').Machine} machine
34
+ */
35
+ function checkBaud(baud, machine) {
36
+ if (machine.model === 1) {
37
+ // The Level II ROM reads only 500 baud (MDL1LEV2.Z80, CTBIT at $0241: one clock pulse and an
38
+ // optional data pulse a bit cell); 1500 baud is the Model III's.
39
+ if (baud !== 500) throw new Error(`The Model I reads cassettes at 500 baud only (1500 is the Model III's rate), not ${String(baud)}.`);
40
+ return;
41
+ }
42
+ if (baud !== 500 && baud !== 1500) throw new Error(`cassette baud must be 500 or 1500 (the Model III's L and H rates), not ${String(baud)}`);
43
+ }
44
+
45
+ /**
46
+ * The pulse tape for `bytes` at `baud`, in T-states of this machine's clock. casToPulses times the
47
+ * tape in Model III T-states; a tape plays in real time, so on the Model I (a slower clock) every
48
+ * time is scaled by the clocks' ratio, which keeps the 500-baud bit cell at its 2 ms.
49
+ * @param {import('../machine/machine.js').Machine} machine
50
+ * @param {Uint8Array} bytes
51
+ * @param {500 | 1500} baud
52
+ */
53
+ function pulsesFor(machine, bytes, baud) {
54
+ const times = casToPulses(tapeForBaud(bytes, baud), baud);
55
+ const scale = machine.system.clockHz / CPU_CLOCK_HZ;
56
+ return scale === 1 ? times : times.map((t) => Math.round(t * scale));
57
+ }
58
+
59
+ /**
60
+ * The tape as the deck plays it at `baud`: the file's own leader and sync (00s or 55s, then A5
61
+ * or 7F, any of which may be missing) replaced by 255 leader bytes and the sync byte of that
62
+ * rate, 00s and A5 at 500 baud, 55s and 7F at 1500, which is what the ROM hunts for.
63
+ * @param {Uint8Array} bytes
64
+ * @param {500 | 1500} baud
65
+ */
66
+ export function tapeForBaud(bytes, baud) {
67
+ let i = 0;
68
+ while (i < bytes.length && (bytes[i] === 0x00 || bytes[i] === 0x55)) i++;
69
+ // Leader bytes not followed by a sync are content (a bare SYSTEM tape starts with its 55).
70
+ const body = bytes[i] === 0xa5 || bytes[i] === 0x7f ? i + 1 : 0;
71
+ const out = new Uint8Array(256 + bytes.length - body);
72
+ out.fill(baud === 500 ? 0x00 : 0x55, 0, 255);
73
+ out[255] = baud === 500 ? 0xa5 : 0x7f;
74
+ out.set(bytes.subarray(body), 256);
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * The cassette deck: insert a .CAS as a pulse tape at a baud, rewind it, read the motor relay.
80
+ * @param {import('../machine/machine.js').Machine} machine
81
+ */
82
+ export function cassetteDeck(machine) {
83
+ const deck = machine.system.io.cassette;
84
+ return {
85
+ /**
86
+ * @param {Uint8Array} bytes a .CAS image
87
+ * @param {{ baud?: 500 | 1500 }} [opts]
88
+ */
89
+ insert(bytes, { baud = 500 } = {}) {
90
+ checkBaud(baud, machine);
91
+ deck.insertPulseTape(pulsesFor(machine, bytes, baud));
92
+ },
93
+ /** Back to the start of the tape. */
94
+ rewind() {
95
+ deck.rewindPulseTape();
96
+ },
97
+ /** True while the ROM holds the motor relay on. */
98
+ get motor() {
99
+ return deck.motorOn;
100
+ },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Load a .CMD: write its 01 blocks and leave the machine ready to run at the transfer address.
106
+ * Text fed or typed for the previous program is dropped (the keyboard queue is cleared, queued
107
+ * keys released), so the new program's first keyboard read does not get stale input.
108
+ * @param {import('../machine/machine.js').Machine} machine
109
+ * @param {Uint8Array} bytes
110
+ * @returns {CmdManifest}
111
+ */
112
+ export function loadCmd(machine, bytes) {
113
+ const parsed = parseCmd(bytes);
114
+ fastLoadCmd(machine.system, parsed);
115
+ machine.keyboard.clear();
116
+ return {
117
+ format: 'cmd',
118
+ name: parsed.name,
119
+ entry: parsed.entry,
120
+ blocks: parsed.blocks.map((b) => ({ addr: b.addr, len: b.data.length, why: 'record 01' })),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Load a .CAS, through the ROM's loader (the tape goes in the deck; the caller types CLOAD, or
126
+ * SYSTEM and the name then `/`) or fast (written now; a SYSTEM tape also jumps to its entry).
127
+ * `baud` applies only via 'rom'; via 'fast' it is ignored and the manifest's `baud` is null.
128
+ * Via 'fast' the keyboard queue is cleared as `load.cmd` does; via 'rom' it is left alone, since
129
+ * the caller types CLOAD or SYSTEM next.
130
+ * @param {import('../machine/machine.js').Machine} machine
131
+ * @param {Uint8Array} bytes
132
+ * @param {{ baud?: 500 | 1500, via?: 'rom' | 'fast' }} [opts]
133
+ * @returns {CasManifest}
134
+ */
135
+ export function loadCas(machine, bytes, { baud = 500, via = 'rom' } = {}) {
136
+ if (via !== 'rom' && via !== 'fast') throw new Error(`load.cas: via must be 'rom' or 'fast', not ${String(via)}`);
137
+ if (via === 'rom') checkBaud(baud, machine);
138
+ const parsed = parseCas(bytes);
139
+ const system = machine.system;
140
+
141
+ /** @type {ManifestBlock[]} */
142
+ let blocks;
143
+ if (parsed.kind === 'system') {
144
+ blocks = parsed.blocks.map((b) => ({ addr: b.addr, len: b.data.length, why: 'block 3C' }));
145
+ } else {
146
+ // CLOAD and the fast path both put the program at TXTTAB: per line a pointer, a line number, the body, and 00, then 00 00.
147
+ const start = system.memory.readWord(TXTTAB_PTR);
148
+ const len = parsed.lines.reduce((n, l) => n + 5 + l.tokens.length, 2);
149
+ blocks = [{ addr: start, len, why: via === 'rom' ? 'BASIC program (predicted from TXTTAB; CLOAD not yet run)' : 'BASIC program' }];
150
+ }
151
+ // Via 'rom' nothing has landed until the ROM reads the tape: every block is where it will go.
152
+ if (via === 'rom') blocks = blocks.map((b) => ({ ...b, predicted: /** @type {true} */ (true) }));
153
+
154
+ if (via === 'rom') {
155
+ system.io.cassette.insertPulseTape(pulsesFor(machine, bytes, baud));
156
+ } else if (parsed.kind === 'system') {
157
+ fastLoadSystem(system, parsed);
158
+ } else {
159
+ fastLoadBasic(system, parsed);
160
+ }
161
+ if (via === 'fast') machine.keyboard.clear();
162
+
163
+ const head = { format: /** @type {'cas'} */ ('cas'), kind: parsed.kind, name: parsed.name };
164
+ const tail = { blocks, baud: via === 'rom' ? baud : null, via };
165
+ return parsed.kind === 'system' ? { ...head, entry: parsed.entry, ...tail } : { ...head, ...tail };
166
+ }
167
+
168
+ /**
169
+ * What a mounted disk looks like from outside: its container and geometry, as `DiskImage` parsed
170
+ * them, and whether writes are refused.
171
+ * @param {number} drive
172
+ * @param {DiskImage} disk
173
+ * @returns {DriveInfo}
174
+ */
175
+ function driveInfo(drive, disk) {
176
+ const container = /** @type {'jv1' | 'jv3' | 'dmk'} */ (disk.format);
177
+ const tracks = disk.trackCount();
178
+ const sides = disk.sideCount();
179
+ /** @type {Set<number>} */
180
+ const counts = new Set();
181
+ if (container === 'jv1') counts.add(disk.sectorsPerTrack);
182
+ else {
183
+ for (let t = 0; t < tracks; t++) for (let side = 0; side < sides; side++) counts.add(disk.sectorsOn(t, side).length);
184
+ }
185
+ const info = { drive, container, tracks, sides };
186
+ const [spt] = counts;
187
+ return counts.size === 1 && spt > 0 ? { ...info, sectorsPerTrack: spt, writeProtected: disk.writeProtected } : { ...info, writeProtected: disk.writeProtected };
188
+ }
189
+
190
+ function checkDrive(op, drive) {
191
+ if (!Number.isInteger(drive) || drive < 0 || drive > 3) throw new Error(`${op}: drive must be 0, 1, 2, or 3, not ${String(drive)}`);
192
+ }
193
+
194
+ /**
195
+ * The four floppy drives of the Model III's WD1793 controller, or of the Model I Expansion
196
+ * Interface's WD1771 (spec §3, §6), over `TRS80System.mountDisk` and `ejectDisk`. With no disk in
197
+ * any drive the controller's ports (on the Model I its addresses $37EC-$37EF) float (read $FF),
198
+ * which is what sends the ROM to cassette BASIC (Level II BASIC on the Model I) at reset, so a
199
+ * machine with nothing mounted boots as it did before disks existed. A Model I built without the
200
+ * Expansion Interface has no disk controller, and refuses a mount. On a Model I a 10-sector JV1
201
+ * may have 35 to 42, 77, or 80 tracks (disk-image.js, JV1_MODEL1_SD_TRACKS).
202
+ * @param {import('../machine/machine.js').Machine} machine
203
+ */
204
+ export function diskDrives(machine) {
205
+ const system = machine.system;
206
+ const model1 = system.model.id === 1;
207
+ return {
208
+ /**
209
+ * Put a disk in a drive, replacing what was there. Bytes are copied into a new `DiskImage`
210
+ * (the container is detected from the content: DMK, then JV3, then JV1 by size); a
211
+ * `DiskImage` is mounted as it is, so writes the machine makes show in it.
212
+ * @param {number} drive 0..3
213
+ * @param {Uint8Array | DiskImage} image
214
+ * @param {{ writeProtect?: boolean }} [opts] `writeProtect` overrides the image's own flag (the DMK or JV3 header's); a passed `DiskImage` has its `writeProtected` set
215
+ * @returns {DriveInfo}
216
+ */
217
+ mount(drive, image, { writeProtect } = {}) {
218
+ checkDrive('fdc.mount', drive);
219
+ if (writeProtect !== undefined && typeof writeProtect !== 'boolean') throw new Error(`fdc.mount: writeProtect must be true or false, not ${String(writeProtect)}`);
220
+ if (model1 && system.io.latch === null) {
221
+ throw new Error('fdc.mount: this Model I has no Expansion Interface, so no disk controller; construct the Machine with expansion: true (the default) to mount a disk');
222
+ }
223
+ let disk;
224
+ if (image instanceof DiskImage) disk = image;
225
+ else if (ArrayBuffer.isView(image) && image.BYTES_PER_ELEMENT === 1) {
226
+ try {
227
+ disk = new DiskImage(/** @type {Uint8Array} */ (image), `drive ${drive}`, model1 ? { jv1SdTracks: JV1_MODEL1_SD_TRACKS } : {});
228
+ } catch (e) {
229
+ throw new Error(`fdc.mount: ${/** @type {Error} */ (e).message}`);
230
+ }
231
+ } else throw new Error('fdc.mount: the image must be a Uint8Array of a JV1, JV3, or DMK file, or a DiskImage');
232
+ if (writeProtect !== undefined) disk.writeProtected = writeProtect;
233
+ system.mountDisk(drive, disk);
234
+ return driveInfo(drive, disk);
235
+ },
236
+ /**
237
+ * Take the disk out of a drive.
238
+ * @param {number} drive 0..3
239
+ * @returns {boolean} true when there was a disk to take out
240
+ */
241
+ unmount(drive) {
242
+ checkDrive('fdc.unmount', drive);
243
+ const had = system.io.fdc.drives[drive] !== null;
244
+ system.ejectDisk(drive);
245
+ return had;
246
+ },
247
+ /**
248
+ * The four drives, in order: each one's mount info, or null when empty. Read from the
249
+ * controller each time, so it follows a snapshot restore.
250
+ * @returns {(DriveInfo | null)[]}
251
+ */
252
+ get drives() {
253
+ return system.io.fdc.drives.map((/** @type {DiskImage | null} */ d, i) => (d === null ? null : driveInfo(i, d)));
254
+ },
255
+ /**
256
+ * The disk in a drive as DMK bytes (spec §6: read JV1, JV3, and DMK; write DMK only), with
257
+ * every write the machine has made to it: a DMK as it now stands, a JV1 or JV3 laid out as
258
+ * raw tracks with each sector's ID, density, data mark, and CRCs (`DiskImage.toDMK`). The
259
+ * bytes mount back with the same sectors.
260
+ * @param {number} drive 0..3
261
+ * @returns {Uint8Array}
262
+ */
263
+ export(drive) {
264
+ checkDrive('fdc.export', drive);
265
+ const disk = system.io.fdc.drives[drive];
266
+ if (disk === null) throw new Error(`fdc.export: drive ${drive} is empty`);
267
+ return disk.toDMK();
268
+ },
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Load a disk image: mount it, and with `boot` reset the machine so the ROM boots from drive 0
274
+ * (the Model III ROM reads track 0, sector 1 to $4300 and jumps there, ROM $34D9-$3516; the Model I
275
+ * Level II ROM reads track 0, sector 0 to $4200 and jumps there, MDL1LEV2.Z80 BOOT at $069F-$06C9,
276
+ * after its PAUSE at $06AF, 65,536 passes of 26 T-states, about a second). Nothing is in memory
277
+ * when this returns, so the manifest's `blocks` is empty. Booting needs a disk in drive 0, this one
278
+ * or one mounted before. A reset clears the keyboard queue; without `boot` it is left alone.
279
+ * @param {import('../machine/machine.js').Machine} machine
280
+ * @param {Uint8Array} bytes
281
+ * @param {{ drive?: number, boot?: boolean }} [opts]
282
+ * @returns {DskManifest}
283
+ */
284
+ export function loadDsk(machine, bytes, { drive = 0, boot = true } = {}) {
285
+ checkDrive('load.dsk', drive);
286
+ if (typeof boot !== 'boolean') throw new Error(`load.dsk: boot must be true or false, not ${String(boot)}`);
287
+ if (!(ArrayBuffer.isView(bytes) && bytes.BYTES_PER_ELEMENT === 1)) throw new Error('load.dsk: bytes must be a Uint8Array of a JV1, JV3, or DMK file');
288
+ if (boot && drive !== 0 && machine.system.io.fdc.drives[0] === null) {
289
+ throw new Error(`load.dsk: the ROM boots from drive 0, which is empty; mount a disk there first, or load into drive 0, or pass boot: false`);
290
+ }
291
+ let info;
292
+ try {
293
+ info = machine.fdc.mount(drive, bytes);
294
+ } catch (e) {
295
+ throw new Error(/** @type {Error} */ (e).message.replace(/^fdc\.mount/, 'load.dsk'));
296
+ }
297
+ if (boot) machine.reset();
298
+ return { format: 'dsk', container: info.container, drive, boot, blocks: [] };
299
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Each model's drive-select latch, decoded into the controller's neutral `select(drive, side,
3
+ * mfm)` (devices/fdc-wd1793.js). A module of its own so the controllers and the I/O decode
4
+ * (machine/io.js, which re-exports these) can import it without an import cycle.
5
+ */
6
+
7
+ /**
8
+ * Decode a Model III port F4 write: bits 0-3 select drive 0-3 (the lowest set bit wins; none set
9
+ * selects no drive, -1), bit 4 the side, bit 6 WAIT, bit 7 MFM (1) or FM (0); bit 5, write
10
+ * precompensation, does nothing here. Bits from MAME's trs80m3 driver and the ROM's use, not yet
11
+ * checked against the Model III Technical Reference Manual (spec §5).
12
+ * @param {number} value
13
+ * @returns {{ drive: number, side: number, mfm: boolean, wait: boolean }}
14
+ */
15
+ export function decodeModel3Select(value) {
16
+ let drive = -1;
17
+ if (value & 0x01) drive = 0;
18
+ else if (value & 0x02) drive = 1;
19
+ else if (value & 0x04) drive = 2;
20
+ else if (value & 0x08) drive = 3;
21
+ return { drive, side: value & 0x10 ? 1 : 0, mfm: (value & 0x80) !== 0, wait: (value & 0x40) !== 0 };
22
+ }
23
+
24
+ /**
25
+ * Decode a write to the Model I Expansion Interface's drive-select latch at $37E1: "a 4-bit drive
26
+ * select indicator sent to the drive" (Kitsz, The Custom TRS-80 and Other Mysteries, ch. 9,
27
+ * pp. 205-206), bits 0-3 for drives 0-3; the Level II ROM selects drive 0 by writing 1
28
+ * (MDL1LEV2.Z80, BOOT at $069F). The lowest set bit wins, as on the Model III (the hardware would
29
+ * select every drive whose bit is set); none set selects no drive, -1. The stock interface is
30
+ * single-sided and single-density, so bits 4-7 do nothing here (double-density and side-select
31
+ * modifications used them; not modelled).
32
+ * @param {number} value
33
+ * @returns {{ drive: number }}
34
+ */
35
+ export function decodeModel1Select(value) {
36
+ let drive = -1;
37
+ if (value & 0x01) drive = 0;
38
+ else if (value & 0x02) drive = 1;
39
+ else if (value & 0x04) drive = 2;
40
+ else if (value & 0x08) drive = 3;
41
+ return { drive };
42
+ }