@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.
- package/LICENSE +21 -0
- package/NOTICE.md +27 -0
- package/README.md +119 -2
- package/package.json +30 -3
- package/src/cpu/z80cpu.js +3343 -0
- package/src/debug/debugger.js +777 -0
- package/src/debug/instr.js +90 -0
- package/src/debug/label-file.js +423 -0
- package/src/debug/state.js +214 -0
- package/src/devices/cassette-tape.js +254 -0
- package/src/devices/cassette.js +259 -0
- package/src/devices/disk-image.js +578 -0
- package/src/devices/fdc-wd1793.js +466 -0
- package/src/devices/key-queue.js +261 -0
- package/src/devices/keyboard.js +176 -0
- package/src/devices/printer.js +44 -0
- package/src/devices/sound.js +53 -0
- package/src/devices/video.js +66 -0
- package/src/index.js +22 -0
- package/src/loaders/bas.js +107 -0
- package/src/loaders/cas-format.js +213 -0
- package/src/loaders/cmd-format.js +88 -0
- package/src/loaders/handoff.js +29 -0
- package/src/loaders/load.js +135 -0
- package/src/machine/io.js +290 -0
- package/src/machine/machine.js +295 -0
- package/src/machine/memory.js +207 -0
- package/src/machine/rom-hashes.js +14 -0
- package/src/machine/sha256.js +74 -0
- package/src/machine/trs80-system.js +232 -0
|
@@ -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
|
+
}
|