@fugloy/emu 0.0.0-stage → 0.2.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,214 @@
1
+ /**
2
+ * Save states: serialize a running TRS80System to a JSON-able object and
3
+ * restore it into a system booted from the same ROM.
4
+ *
5
+ * The state carries everything the machine needs to resume mid-program:
6
+ * CPU registers and interrupt state, RAM and video RAM, the I/O latches,
7
+ * the FDC (including an in-flight command), the cassette tape, and the
8
+ * full contents of every mounted disk (writes are in-memory, so the disk
9
+ * bytes ARE the disk state). The ROM itself is NOT included — a state is
10
+ * a snapshot of a machine, not a machine image.
11
+ */
12
+
13
+ import { DiskImage } from "../devices/disk-image.js";
14
+
15
+ export const STATE_VERSION = 1;
16
+ export const STATE_MACHINE = "trs80-model3";
17
+
18
+ // Base64 without platform APIs, so the core runs anywhere (spec §2.1).
19
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
20
+
21
+ export function toBase64(bytes) {
22
+ let text = "";
23
+ for (let i = 0; i < bytes.length; i += 3) {
24
+ const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
25
+ text += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
26
+ text += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "=";
27
+ text += i + 2 < bytes.length ? B64[n & 63] : "=";
28
+ }
29
+ return text;
30
+ }
31
+
32
+ export function fromBase64(text) {
33
+ // Whitespace and the URL-safe alphabet are accepted, as the Buffer decoder upstream used them.
34
+ const clean = text.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
35
+ const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4));
36
+ let bits = 0;
37
+ let value = 0;
38
+ let out = 0;
39
+ for (const ch of clean) {
40
+ const v = B64.indexOf(ch);
41
+ if (v < 0) throw new Error(`not base64: ${JSON.stringify(ch)}`);
42
+ value = (value << 6) | v;
43
+ bits += 6;
44
+ if (bits >= 8) {
45
+ bits -= 8;
46
+ bytes[out++] = (value >> bits) & 0xff;
47
+ }
48
+ }
49
+ return bytes;
50
+ }
51
+
52
+ const CPU_SCALARS = [
53
+ "IFF1",
54
+ "IFF2",
55
+ "interruptMode",
56
+ "halted",
57
+ "pendingEI",
58
+ "wz",
59
+ "afterPrefix",
60
+ "cycles",
61
+ ];
62
+ const IO_SCALARS = [
63
+ "intLatch",
64
+ "intMask",
65
+ "nmiMask",
66
+ "modeRegister",
67
+ "cassetteOut",
68
+ ];
69
+ const FDC_SCALARS = [
70
+ "selected",
71
+ "side",
72
+ "trackReg",
73
+ "sectorReg",
74
+ "dataReg",
75
+ "status",
76
+ "intrq",
77
+ "commandType",
78
+ "bufferPos",
79
+ "writing",
80
+ "writeDeleted",
81
+ "mfm",
82
+ "indexClock",
83
+ ];
84
+
85
+ export function serializeState(system) {
86
+ const cpu = system.cpu;
87
+ const io = system.io;
88
+ const fdc = io.fdc;
89
+
90
+ const registers = {};
91
+ for (const name of Object.keys(cpu.registers)) {
92
+ registers[name] = cpu.registers[name];
93
+ }
94
+
95
+ const state = {
96
+ version: STATE_VERSION,
97
+ machine: STATE_MACHINE,
98
+ cpu: { registers },
99
+ memory: {
100
+ ram: toBase64(system.memory.ram),
101
+ videoRam: toBase64(system.memory.videoRam),
102
+ },
103
+ io: {},
104
+ fdc: {
105
+ physicalTrack: [...fdc.physicalTrack],
106
+ pending: fdc.pending ? { ...fdc.pending } : null,
107
+ buffer: fdc.buffer ? toBase64(fdc.buffer) : null,
108
+ trackWrite: fdc.trackWrite ? toBase64(new Uint8Array(fdc.trackWrite)) : null,
109
+ },
110
+ cassette: {
111
+ tape: io.cassette.tapeData ? toBase64(io.cassette.tapeData) : null,
112
+ position: io.cassette.tapePosition,
113
+ motorOn: io.cassette.motorOn,
114
+ // The CSAVE recording travels as its DECODED .cas bytes (small);
115
+ // a pulse tape mid-play is deliberately not serialized — pulse
116
+ // streams are hundreds of KB and re-inserting a tape is one
117
+ // menu click.
118
+ recorded: (() => {
119
+ const rec = io.cassette.recordedCas();
120
+ return rec.bytes.length ? toBase64(rec.bytes) : null;
121
+ })(),
122
+ },
123
+ printer: { bytes: [...io.printer.bytes] },
124
+ disks: [],
125
+ nextRtcAt: system.nextRtcAt,
126
+ };
127
+ for (const key of CPU_SCALARS) state.cpu[key] = cpu[key];
128
+ for (const key of IO_SCALARS) state.io[key] = io[key];
129
+ for (const key of FDC_SCALARS) state.fdc[key] = fdc[key];
130
+
131
+ fdc.drives.forEach((disk, drive) => {
132
+ if (!disk) return;
133
+ state.disks.push({
134
+ drive,
135
+ name: disk.name,
136
+ bytes: toBase64(disk.bytes),
137
+ sectorBase: disk.sectorBase,
138
+ writeProtected: disk.writeProtected,
139
+ });
140
+ });
141
+
142
+ return state;
143
+ }
144
+
145
+ /**
146
+ * Restore a serialized state into a system built from the same ROM.
147
+ * Throws on version/machine mismatch rather than resuming garbage.
148
+ */
149
+ export function restoreState(system, state) {
150
+ if (!state || state.machine !== STATE_MACHINE) {
151
+ throw new Error("Not a TRS-80 Model III save state");
152
+ }
153
+ if (state.version !== STATE_VERSION) {
154
+ throw new Error(`Unsupported save-state version ${state.version}`);
155
+ }
156
+
157
+ const cpu = system.cpu;
158
+ const io = system.io;
159
+ const fdc = io.fdc;
160
+
161
+ for (const [name, value] of Object.entries(state.cpu.registers)) {
162
+ cpu.registers[name] = value;
163
+ }
164
+ for (const key of CPU_SCALARS) cpu[key] = state.cpu[key];
165
+ cpu.wz ??= 0; // absent in version-1 saves from before emu phase 1
166
+ cpu.afterPrefix ??= false; // likewise
167
+ for (const key of IO_SCALARS) io[key] = state.io[key];
168
+
169
+ system.memory.ram.set(fromBase64(state.memory.ram));
170
+ system.memory.videoRam.set(fromBase64(state.memory.videoRam));
171
+ system.memory.videoDirty = true;
172
+
173
+ for (const key of FDC_SCALARS) fdc[key] = state.fdc[key];
174
+ fdc.writeDeleted = !!fdc.writeDeleted; // absent in version-1 saves from before DAM support
175
+ fdc.mfm = state.fdc.mfm ?? true; // absent before WRITE TRACK support
176
+ fdc.physicalTrack = [...state.fdc.physicalTrack];
177
+ fdc.pending = state.fdc.pending ? { ...state.fdc.pending } : null;
178
+ fdc.buffer = state.fdc.buffer ? fromBase64(state.fdc.buffer) : null;
179
+ fdc.trackWrite = state.fdc.trackWrite
180
+ ? Array.from(fromBase64(state.fdc.trackWrite))
181
+ : null;
182
+
183
+ // Absent in saves from before the printer existed: clear the paper.
184
+ io.printer.bytes = state.printer?.bytes ? [...state.printer.bytes] : [];
185
+
186
+ io.cassette.eject();
187
+ if (state.cassette.tape) {
188
+ io.cassette.loadTape(fromBase64(state.cassette.tape));
189
+ io.cassette.tapePosition = state.cassette.position;
190
+ }
191
+ io.cassette.motorOn = state.cassette.motorOn;
192
+ io.cassette.recordLog = [];
193
+ io.cassette.restoredRecording = state.cassette.recorded
194
+ ? fromBase64(state.cassette.recorded)
195
+ : null;
196
+ io.cassette.lastCycle = cpu.cycles; // don't let playClock leap on resume
197
+
198
+ fdc.drives = [null, null, null, null];
199
+ 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;
203
+ disk.writeProtected = entry.writeProtected;
204
+ fdc.drives[entry.drive & 3] = disk;
205
+ }
206
+
207
+ system.nextRtcAt = state.nextRtcAt;
208
+ system._nmiWasPending = false;
209
+ system.keyboard.reset(); // held keys don't survive a restore
210
+ io.soundLog = [];
211
+ io.onSoundReset?.(); // the Machine's raw sound ring belongs to the old timeline
212
+
213
+ return system;
214
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Model III cassette pulse codec: .cas bytes <-> port-0xFF transition
3
+ * times (CPU T-states).
4
+ *
5
+ * The timing constants were measured from the real ROM's own CSAVE
6
+ * output (scripts/probe-cassette-timing.js):
7
+ *
8
+ * 1500 baud ("H" at Cass?, the Model III default) is a square wave,
9
+ * one full cycle per bit — half-cycles of ~379 T-states for a 1 and
10
+ * ~768 for a 0, with the half at a byte boundary stretched ~140 T by
11
+ * the ROM's bookkeeping (which still classifies correctly: a
12
+ * stretched 1-half is ~522, a stretched 0-half ~911, and the
13
+ * threshold sits between at 575).
14
+ *
15
+ * 500 baud ("L") is the Model I scheme: a ~190+190 T pulse pair as
16
+ * the clock at every 4060 T bit cell, plus an identical data pulse
17
+ * at mid-cell (+2030) when the bit is 1.
18
+ *
19
+ * Bits are MSB-first. Decoding recovers byte framing by trying all 8
20
+ * bit alignments and keeping the one that shows the longest leader run
21
+ * (0x55 for 1500 baud, 0x00 for 500), so a clipped capture still
22
+ * decodes.
23
+ */
24
+
25
+ // 1500 baud ("H") half-cycle lengths
26
+ const H_SHORT = 379; // a 1 bit
27
+ const H_LONG = 768; // a 0 bit
28
+ const H_THRESHOLD = 575;
29
+
30
+ // 500 baud ("L") pulse shape and cell. The byte gap matters: the real
31
+ // CSAVE stretches the cell after every byte by ~109 T, and the reader
32
+ // is tuned to it — without the gap, the next byte's clock pulse rises
33
+ // inside the reader's post-sample latch reset and gets wiped, slipping
34
+ // its phase onto data pulses.
35
+ const L_PULSE = 189;
36
+ const L_CELL = 4061;
37
+ const L_HALF = 2042;
38
+ // The real CSAVE stretches the cell after every byte while it does its
39
+ // bookkeeping — variably, up to a couple hundred T — and the reader
40
+ // needs that slack or the next clock pulse rises inside its post-
41
+ // sample latch reset and gets wiped. The reader has no timeout, so a
42
+ // generously padded gap is strictly safe.
43
+ const L_BYTE_GAP = 600;
44
+ // The real CSAVE pauses an extra half-cell after the 0xA5 sync byte —
45
+ // the reader does its post-sync setup in that window.
46
+ const L_SYNC_GAP = 2033;
47
+ // A gap from clock-pulse start shorter than this is the mid-cell data
48
+ // pulse (a 1); a full-cell gap (even stretched at byte ends) is a 0.
49
+ const L_DATA_WINDOW = 3040;
50
+ // Transitions closer than this belong to the same physical pulse
51
+ const L_PULSE_SPAN = 400;
52
+
53
+ /** Encode a .cas byte stream as transition times at the given baud. */
54
+ export function casToPulses(bytes, baud) {
55
+ return baud === 500 ? encode500(bytes) : encode1500(bytes);
56
+ }
57
+
58
+ function encode1500(bytes) {
59
+ // Wire framing (measured from the ROM): the leader and its 0x7F sync
60
+ // are plain 8-bit; every byte AFTER the sync is preceded by a 0
61
+ // start bit the ROM's byte reader hunts for.
62
+ let syncIdx = -1;
63
+ let i = 0;
64
+ while (i < bytes.length && bytes[i] === 0x55) i++;
65
+ if (i > 0 && bytes[i] === 0x7f) syncIdx = i;
66
+
67
+ const times = [0]; // the wave's first edge — halves are edge-to-edge
68
+ let t = 0;
69
+ const cycle = (bit) => {
70
+ const half = bit ? H_SHORT : H_LONG;
71
+ times.push((t += half));
72
+ times.push((t += half));
73
+ };
74
+ for (let k = 0; k < bytes.length; k++) {
75
+ if (syncIdx >= 0 && k > syncIdx) cycle(0); // start bit
76
+ for (let bit = 7; bit >= 0; bit--) cycle((bytes[k] >> bit) & 1);
77
+ }
78
+ return times;
79
+ }
80
+
81
+ function encode500(bytes) {
82
+ // Locate the sync byte (a 0x00 leader run then 0xA5) for its pause
83
+ let syncIdx = -1;
84
+ let i = 0;
85
+ while (i < bytes.length && bytes[i] === 0x00) i++;
86
+ if (i > 0 && bytes[i] === 0xa5) syncIdx = i;
87
+
88
+ const times = [];
89
+ let t = 0;
90
+ // A pulse is a rise + a fall: the level sits high for ~190 T. (The
91
+ // OUTPUT side writes three DAC levels, but the reader's comparator
92
+ // sees one positive excursion.)
93
+ const pulse = (at) => times.push(at, at + L_PULSE);
94
+ for (let k = 0; k < bytes.length; k++) {
95
+ for (let bit = 7; bit >= 0; bit--) {
96
+ pulse(t);
97
+ if ((bytes[k] >> bit) & 1) pulse(t + L_HALF);
98
+ t += L_CELL;
99
+ }
100
+ t += L_BYTE_GAP;
101
+ if (k === syncIdx) t += L_SYNC_GAP;
102
+ }
103
+ return times;
104
+ }
105
+
106
+ /**
107
+ * Decode transition times back into bytes, sniffing the baud.
108
+ * @returns {{bytes: Uint8Array, baud: 500|1500}}
109
+ */
110
+ export function pulsesToBytes(times) {
111
+ if (!times || times.length < 16) {
112
+ return { bytes: new Uint8Array(0), baud: 1500 };
113
+ }
114
+ const intervals = [];
115
+ for (let i = 1; i < times.length; i++) intervals.push(times[i] - times[i - 1]);
116
+
117
+ // 500-baud streams are full of ~190 T intra-pulse gaps; 1500-baud
118
+ // half-cycles never get near that.
119
+ const shorties = intervals.filter((d) => d < 300).length;
120
+ const baud = shorties > intervals.length / 4 ? 500 : 1500;
121
+ if (baud === 500) {
122
+ return { bytes: frame(bits500(times), 0x00, 0xa5).bytes, baud };
123
+ }
124
+ // 1500 baud: cycle pairing depends on which edge starts a bit, and a
125
+ // clipped capture can lose that. Decode both parities and keep the
126
+ // framing the leader+sync scorer likes better.
127
+ const candidates = [0, 1].map((parity) => ({
128
+ parity,
129
+ bits: bits1500(intervals, parity),
130
+ }));
131
+ const framed = candidates.map((c) => ({
132
+ ...c,
133
+ ...frame(c.bits, 0x55, 0x7f),
134
+ }));
135
+ const best = framed[1].score > framed[0].score ? framed[1] : framed[0];
136
+ if (best.syncIndex < 0) {
137
+ return { bytes: best.bytes, baud }; // no sync found: raw 8-bit framing
138
+ }
139
+ // Re-read everything after the sync as start-bit-framed 9-cycle bytes
140
+ const out = Array.from(best.bytes.slice(0, best.syncIndex + 1));
141
+ let p = best.shift + (best.syncIndex + 1) * 8;
142
+ while (p + 9 <= best.bits.length && best.bits[p] === 0) {
143
+ let byte = 0;
144
+ for (let k = 1; k <= 8; k++) byte = (byte << 1) | best.bits[p + k];
145
+ out.push(byte);
146
+ p += 9;
147
+ }
148
+ return { bytes: Uint8Array.from(out), baud };
149
+ }
150
+
151
+ function bits1500(intervals, parity) {
152
+ // Classify FULL cycles (two halves), not halves: the ROM stretches
153
+ // the half at a byte boundary by its bookkeeping time (~230 T),
154
+ // enough to push a stretched 1-half over any half-level threshold,
155
+ // but nowhere near the cycle midpoint (758 vs 1536).
156
+ const bits = [];
157
+ for (let i = parity; i + 1 < intervals.length; i += 2) {
158
+ bits.push(intervals[i] + intervals[i + 1] < 2 * H_THRESHOLD ? 1 : 0);
159
+ }
160
+ return bits;
161
+ }
162
+
163
+ function bits500(times) {
164
+ // Collapse each pulse's internal transitions to its start time
165
+ const starts = [];
166
+ let i = 0;
167
+ while (i < times.length) {
168
+ starts.push(times[i]);
169
+ while (i + 1 < times.length && times[i + 1] - times[i] < L_PULSE_SPAN) i++;
170
+ i++;
171
+ }
172
+ const bits = [];
173
+ let k = 0;
174
+ while (k + 1 < starts.length) {
175
+ if (starts[k + 1] - starts[k] < L_DATA_WINDOW) {
176
+ bits.push(1); // mid-cell data pulse: consume it too
177
+ k += 2;
178
+ } else {
179
+ bits.push(0);
180
+ k += 1;
181
+ }
182
+ }
183
+ // A trailing lone clock pulse is a final 0 bit (a 1 would have had a
184
+ // data pulse after it). Without this, the tape's LAST byte loses its
185
+ // last bit and gets dropped — for BASIC tapes that's the third zero
186
+ // of the end-of-program marker, and CLOAD waits for it forever.
187
+ if (k < starts.length) bits.push(0);
188
+ return bits;
189
+ }
190
+
191
+ /**
192
+ * Pick the bit alignment for byte framing. A repeating leader (0x55
193
+ * alternates, 0x00 is featureless) looks valid at several shifts, so
194
+ * the run length alone can't decide — the shift whose leader run is
195
+ * followed by the real sync byte wins.
196
+ * @returns {{bytes: Uint8Array, score: number, shift: number,
197
+ * syncIndex: number}} syncIndex is the sync byte's position
198
+ * (-1 when the leader+sync pattern never showed up)
199
+ */
200
+ function frame(bits, leaderByte, syncByte) {
201
+ let best = null;
202
+ for (let shift = 0; shift < 8; shift++) {
203
+ const bytes = packBits(bits, shift);
204
+ let run = 0;
205
+ let maxRun = 0;
206
+ let runEnd = -1;
207
+ const scan = Math.min(bytes.length, 400);
208
+ for (let i = 0; i < scan; i++) {
209
+ if (bytes[i] === leaderByte) {
210
+ run++;
211
+ if (run > maxRun) {
212
+ maxRun = run;
213
+ runEnd = i + 1;
214
+ }
215
+ } else {
216
+ run = 0;
217
+ }
218
+ }
219
+ let score = maxRun;
220
+ let syncIndex = -1;
221
+ if (runEnd >= 0 && runEnd < bytes.length && bytes[runEnd] === syncByte) {
222
+ score += 1000;
223
+ syncIndex = runEnd;
224
+ }
225
+ if (!best || score > best.score) best = { score, bytes, shift, syncIndex };
226
+ }
227
+ return best;
228
+ }
229
+
230
+ function packBits(bits, shift) {
231
+ const out = new Uint8Array(Math.max(0, Math.floor((bits.length - shift) / 8)));
232
+ for (let i = 0; i < out.length; i++) {
233
+ let b = 0;
234
+ for (let k = 0; k < 8; k++) b = (b << 1) | bits[shift + i * 8 + k];
235
+ out[i] = b;
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /**
241
+ * Give a bare byte stream the leader + sync the ROM's CLOAD hunts for
242
+ * (0x55.. 0x7F at 1500 baud, 0x00.. 0xA5 at 500). Streams that already
243
+ * start with leader bytes pass through untouched.
244
+ */
245
+ export function ensureLeader(bytes, baud) {
246
+ const leader = baud === 500 ? 0x00 : 0x55;
247
+ const sync = baud === 500 ? 0xa5 : 0x7f;
248
+ if (bytes.length && bytes[0] === leader) return bytes;
249
+ const out = new Uint8Array(255 + 1 + bytes.length);
250
+ out.fill(leader, 0, 255);
251
+ out[255] = sync;
252
+ out.set(bytes, 256);
253
+ return out;
254
+ }