jsbeeb 1.10.0 → 1.12.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 +1 -1
- package/package.json +2 -7
- package/src/6502.js +8 -1
- package/src/app/app.js +1 -1
- package/src/app/electron.js +8 -8
- package/src/bem-snapshot.js +7 -218
- package/src/config.js +79 -62
- package/src/dom-utils.js +32 -0
- package/src/google-drive.js +3 -4
- package/src/keyboard.js +17 -14
- package/src/main.js +267 -235
- package/src/models.js +4 -4
- package/src/snapshot-helpers.js +208 -0
- package/src/snapshot.js +9 -18
- package/src/sth.js +1 -1
- package/src/tapes.js +2 -2
- package/src/uef-snapshot.js +402 -0
- package/src/utils.js +146 -15
- package/src/web/audio-handler.js +41 -25
- package/src/web/debug.js +100 -71
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// BeebEm UEF save state parser.
|
|
4
|
+
// Converts a BeebEm UEF save state file into a jsbeeb snapshot object, in the same way
|
|
5
|
+
// that bem-snapshot.js converts B-em snapshots.
|
|
6
|
+
//
|
|
7
|
+
// BeebEm extends the UEF (Universal Emulator Format) with save-state chunks in the
|
|
8
|
+
// 0x0460-0x047F range, identified by an initial 0x046C (BeebEm ID) chunk.
|
|
9
|
+
// Reference: stardot/beebem-windows Src/UefState.cpp
|
|
10
|
+
|
|
11
|
+
import { volumeTable, buildVideoState, buildSnapshot } from "./snapshot-helpers.js";
|
|
12
|
+
|
|
13
|
+
// Chunk IDs used in BeebEm UEF save states
|
|
14
|
+
const ChunkId = {
|
|
15
|
+
BeebEmID: 0x046c, // presence of this chunk identifies a BeebEm save state
|
|
16
|
+
EmuState: 0x046a, // machine type, FDC type, tube type
|
|
17
|
+
Cpu6502: 0x0460, // 6502 CPU registers and status
|
|
18
|
+
RomRegs: 0x0461, // paged ROM register (FE30) and ACCCON (FE34)
|
|
19
|
+
MainRam: 0x0462, // main RAM (32 KB)
|
|
20
|
+
ShadowRam: 0x0463, // shadow RAM (BBC B+/Master)
|
|
21
|
+
PrivateRam: 0x0464, // private RAM (BBC B+/Master)
|
|
22
|
+
SwRam: 0x0466, // sideways RAM bank (one chunk per bank)
|
|
23
|
+
Via: 0x0467, // VIA state (one chunk per VIA: sys VIA first, then user VIA)
|
|
24
|
+
Video: 0x0468, // video state (CRTC + ULA)
|
|
25
|
+
Sound: 0x046b, // SN76489 sound chip state
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check whether an ArrayBuffer looks like a BeebEm UEF save state.
|
|
30
|
+
* A save state has the "UEF File!" header and its first chunk is the BeebEm ID (0x046C).
|
|
31
|
+
* @param {ArrayBuffer} buffer
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isUefSnapshot(buffer) {
|
|
35
|
+
if (buffer.byteLength < 14) return false;
|
|
36
|
+
const bytes = new Uint8Array(buffer, 0, 14);
|
|
37
|
+
// Bytes 0-9: "UEF File!\0"
|
|
38
|
+
const header = "UEF File!";
|
|
39
|
+
for (let i = 0; i < 9; i++) {
|
|
40
|
+
if (bytes[i] !== header.charCodeAt(i)) return false;
|
|
41
|
+
}
|
|
42
|
+
if (bytes[9] !== 0) return false;
|
|
43
|
+
// Bytes 12-13: first chunk ID (little-endian uint16) must be 0x046C
|
|
44
|
+
const chunkId = bytes[12] | (bytes[13] << 8);
|
|
45
|
+
return chunkId === ChunkId.BeebEmID;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse all UEF chunks from the buffer into a Map<chunkId, Uint8Array[]>.
|
|
50
|
+
* Starts after the 12-byte UEF header (10 bytes signature + 2 bytes version).
|
|
51
|
+
*/
|
|
52
|
+
function parseChunks(bytes, view) {
|
|
53
|
+
const chunks = new Map();
|
|
54
|
+
let offset = 12;
|
|
55
|
+
while (offset + 6 <= bytes.length) {
|
|
56
|
+
const chunkId = view.getUint16(offset, true);
|
|
57
|
+
const chunkLen = view.getUint32(offset + 2, true);
|
|
58
|
+
offset += 6;
|
|
59
|
+
if (offset + chunkLen > bytes.length) break;
|
|
60
|
+
const chunkData = bytes.slice(offset, offset + chunkLen);
|
|
61
|
+
if (!chunks.has(chunkId)) chunks.set(chunkId, []);
|
|
62
|
+
chunks.get(chunkId).push(chunkData);
|
|
63
|
+
offset += chunkLen;
|
|
64
|
+
}
|
|
65
|
+
return chunks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert a BeebEm UEF VIA state (from chunk 0x0467) to jsbeeb VIA state.
|
|
70
|
+
* The UEF chunk layout (with the leading VIAType byte) is:
|
|
71
|
+
* [0] VIAType (0=sys, 1=user)
|
|
72
|
+
* [1] ORB
|
|
73
|
+
* [2] IRB
|
|
74
|
+
* [3] ORA
|
|
75
|
+
* [4] IRA
|
|
76
|
+
* [5] DDRB
|
|
77
|
+
* [6] DDRA
|
|
78
|
+
* [7-8] timer1c / 2 (uint16 LE; BeebEm saves count/2, jsbeeb needs count*2 from file)
|
|
79
|
+
* [9-10] timer1l (uint16 LE; raw 16-bit latch; jsbeeb needs latch*2)
|
|
80
|
+
* [11-12] timer2c / 2 (uint16 LE)
|
|
81
|
+
* [13-14] timer2l (uint16 LE)
|
|
82
|
+
* [15] ACR
|
|
83
|
+
* [16] PCR
|
|
84
|
+
* [17] IFR
|
|
85
|
+
* [18] IER
|
|
86
|
+
* [19] timer1hasshot
|
|
87
|
+
* [20] timer2hasshot
|
|
88
|
+
* [21] IC32State (sys VIA only, absent for user VIA)
|
|
89
|
+
*/
|
|
90
|
+
function convertViaChunk(data) {
|
|
91
|
+
const viaType = data[0];
|
|
92
|
+
if (viaType !== 0 && viaType !== 1) throw new Error(`Unknown VIA type: ${viaType} (expected 0=sys or 1=user)`);
|
|
93
|
+
const minLen = viaType === 0 ? 22 : 21; // sys VIA needs IC32 byte
|
|
94
|
+
if (data.length < minLen) throw new Error(`VIA chunk too short: expected >= ${minLen} bytes, got ${data.length}`);
|
|
95
|
+
|
|
96
|
+
const view = new DataView(data.buffer, data.byteOffset);
|
|
97
|
+
|
|
98
|
+
// BeebEm saves timer counters as counter/2 and loads them as file*2.
|
|
99
|
+
// jsbeeb stores timer values in 2x peripheral cycles (same as BeebEm's internal),
|
|
100
|
+
// so: jsbeeb_t1c = file_t1c * 2, jsbeeb_t1l = file_t1l * 2.
|
|
101
|
+
const t1c = view.getUint16(7, true) * 2;
|
|
102
|
+
const t1l = view.getUint16(9, true) * 2;
|
|
103
|
+
const t2c = view.getUint16(11, true) * 2;
|
|
104
|
+
const t2l = view.getUint16(13, true) * 2;
|
|
105
|
+
|
|
106
|
+
const acr = data[15];
|
|
107
|
+
const pcr = data[16];
|
|
108
|
+
const ifr = data[17];
|
|
109
|
+
const ier = data[18];
|
|
110
|
+
|
|
111
|
+
// Derive CA2/CB2 from PCR, matching BeebEm's LoadViaUEF
|
|
112
|
+
const ca2 = (pcr & 0x0e) === 0x0e;
|
|
113
|
+
const cb2 = (pcr & 0xe0) === 0xe0;
|
|
114
|
+
// Sys VIA (type 0) always needs IC32; default to 0xff if missing
|
|
115
|
+
const ic32 = viaType === 0 ? (data.length > 21 ? data[21] : 0xff) : undefined;
|
|
116
|
+
|
|
117
|
+
const result = {
|
|
118
|
+
ora: data[3],
|
|
119
|
+
orb: data[1],
|
|
120
|
+
ira: data[4],
|
|
121
|
+
irb: data[2],
|
|
122
|
+
ddra: data[6],
|
|
123
|
+
ddrb: data[5],
|
|
124
|
+
sr: 0,
|
|
125
|
+
acr,
|
|
126
|
+
pcr,
|
|
127
|
+
ifr,
|
|
128
|
+
ier,
|
|
129
|
+
t1l,
|
|
130
|
+
t2l,
|
|
131
|
+
t1c,
|
|
132
|
+
t2c,
|
|
133
|
+
// In free-running mode (ACR bit 6), t1hit must be false or jsbeeb
|
|
134
|
+
// will never generate timer 1 interrupts (blocking the 100Hz tick).
|
|
135
|
+
t1hit: acr & 0x40 ? false : !!data[19],
|
|
136
|
+
t2hit: !!data[20],
|
|
137
|
+
portapins: 0xff,
|
|
138
|
+
portbpins: 0xff,
|
|
139
|
+
ca1: false,
|
|
140
|
+
ca2,
|
|
141
|
+
cb1: false,
|
|
142
|
+
cb2,
|
|
143
|
+
justhit: 0,
|
|
144
|
+
t1_pb7: (data[1] >> 7) & 1,
|
|
145
|
+
lastPolltime: 0,
|
|
146
|
+
taskOffset: 1,
|
|
147
|
+
};
|
|
148
|
+
if (ic32 !== undefined) {
|
|
149
|
+
result.IC32 = ic32;
|
|
150
|
+
result.capsLockLight = !(ic32 & 0x40);
|
|
151
|
+
result.shiftLockLight = !(ic32 & 0x80);
|
|
152
|
+
}
|
|
153
|
+
return { viaType, state: result };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convert BeebEm UEF sound chunk (0x046B) to jsbeeb sound chip state.
|
|
158
|
+
*
|
|
159
|
+
* SaveSoundUEF layout (byte offsets within the chunk):
|
|
160
|
+
* [0-1] ToneFreq[2] (uint16 LE) → SN76489 tone channel 0 period
|
|
161
|
+
* [2-3] ToneFreq[1] (uint16 LE) → SN76489 tone channel 1 period
|
|
162
|
+
* [4-5] ToneFreq[0] (uint16 LE) → SN76489 tone channel 2 period
|
|
163
|
+
* [6] RealVolumes[3] → tone channel 0 volume register (0=loud, 15=silent)
|
|
164
|
+
* [7] RealVolumes[2] → tone channel 1 volume register
|
|
165
|
+
* [8] RealVolumes[1] → tone channel 2 volume register
|
|
166
|
+
* [9] Noise = (Noise.Freq | (Noise.FB << 2)) → noise register bits 0-2
|
|
167
|
+
* [10] RealVolumes[0] → noise channel volume register
|
|
168
|
+
* [11] LastToneFreqSet (ignored)
|
|
169
|
+
* [12+] GenIndex[0..3] (ignored)
|
|
170
|
+
*/
|
|
171
|
+
function convertSoundChunk(data) {
|
|
172
|
+
const registers = new Uint16Array(4);
|
|
173
|
+
const counter = new Float32Array(4);
|
|
174
|
+
const outputBit = [false, false, false, false];
|
|
175
|
+
const volume = new Float32Array(4);
|
|
176
|
+
|
|
177
|
+
if (data && data.length >= 11) {
|
|
178
|
+
const view = new DataView(data.buffer, data.byteOffset);
|
|
179
|
+
// BeebEm's ToneFreq array is indexed in reverse relative to SN76489 channels:
|
|
180
|
+
// ToneFreq[2] → SN76489 channel 0, ToneFreq[1] → channel 1, ToneFreq[0] → channel 2.
|
|
181
|
+
// Similarly, RealVolumes[3,2,1,0] map to channels [0,1,2,noise].
|
|
182
|
+
registers[0] = view.getUint16(0, true); // ToneFreq[2] → ch 0
|
|
183
|
+
registers[1] = view.getUint16(2, true); // ToneFreq[1] → ch 1
|
|
184
|
+
registers[2] = view.getUint16(4, true); // ToneFreq[0] → ch 2
|
|
185
|
+
registers[3] = data[9] & 0x07; // noise register
|
|
186
|
+
|
|
187
|
+
const vol0 = data[6] & 0x0f; // channel 0 volume
|
|
188
|
+
const vol1 = data[7] & 0x0f; // channel 1 volume
|
|
189
|
+
const vol2 = data[8] & 0x0f; // channel 2 volume
|
|
190
|
+
const vol3 = data[10] & 0x0f; // noise volume
|
|
191
|
+
|
|
192
|
+
volume[0] = volumeTable[vol0];
|
|
193
|
+
volume[1] = volumeTable[vol1];
|
|
194
|
+
volume[2] = volumeTable[vol2];
|
|
195
|
+
volume[3] = volumeTable[vol3];
|
|
196
|
+
|
|
197
|
+
// Approximate outputBit from volume: if a channel is silent (volume register = 15),
|
|
198
|
+
// its output bit is off. Not cycle-accurate but produces a reasonable initial state.
|
|
199
|
+
outputBit[0] = vol0 !== 15;
|
|
200
|
+
outputBit[1] = vol1 !== 15;
|
|
201
|
+
outputBit[2] = vol2 !== 15;
|
|
202
|
+
outputBit[3] = vol3 !== 15;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
registers,
|
|
207
|
+
counter,
|
|
208
|
+
outputBit,
|
|
209
|
+
volume,
|
|
210
|
+
lfsr: 1 << 14,
|
|
211
|
+
latchedRegister: 0,
|
|
212
|
+
residual: 0,
|
|
213
|
+
sineOn: false,
|
|
214
|
+
sineStep: 0,
|
|
215
|
+
sineTime: 0,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Default jsbeeb VIA state used when no VIA chunk is present.
|
|
221
|
+
* @param {number|undefined} ic32 - IC32 value (sys VIA only)
|
|
222
|
+
*/
|
|
223
|
+
function defaultViaState(ic32) {
|
|
224
|
+
const result = {
|
|
225
|
+
ora: 0xff,
|
|
226
|
+
orb: 0xff,
|
|
227
|
+
ira: 0xff,
|
|
228
|
+
irb: 0xff,
|
|
229
|
+
ddra: 0x00,
|
|
230
|
+
ddrb: 0x00,
|
|
231
|
+
sr: 0,
|
|
232
|
+
acr: 0x00,
|
|
233
|
+
pcr: 0x00,
|
|
234
|
+
ifr: 0x00,
|
|
235
|
+
ier: 0x80,
|
|
236
|
+
t1l: 0x1fffe,
|
|
237
|
+
t2l: 0x1fffe,
|
|
238
|
+
t1c: 0x1fffe,
|
|
239
|
+
t2c: 0x1fffe,
|
|
240
|
+
t1hit: true,
|
|
241
|
+
t2hit: true,
|
|
242
|
+
portapins: 0xff,
|
|
243
|
+
portbpins: 0xff,
|
|
244
|
+
ca1: false,
|
|
245
|
+
ca2: false,
|
|
246
|
+
cb1: false,
|
|
247
|
+
cb2: false,
|
|
248
|
+
justhit: 0,
|
|
249
|
+
t1_pb7: 1,
|
|
250
|
+
lastPolltime: 0,
|
|
251
|
+
taskOffset: 1,
|
|
252
|
+
};
|
|
253
|
+
if (ic32 !== undefined) {
|
|
254
|
+
result.IC32 = ic32;
|
|
255
|
+
result.capsLockLight = !(ic32 & 0x40);
|
|
256
|
+
result.shiftLockLight = !(ic32 & 0x80);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Parse a BeebEm UEF save state into a jsbeeb snapshot object.
|
|
263
|
+
* @param {ArrayBuffer} buffer
|
|
264
|
+
* @returns {object} jsbeeb snapshot
|
|
265
|
+
*/
|
|
266
|
+
export function parseUefSnapshot(buffer) {
|
|
267
|
+
if (buffer.byteLength < 14) throw new Error("File too small to be a BeebEm UEF save state");
|
|
268
|
+
if (!isUefSnapshot(buffer)) throw new Error("Not a BeebEm UEF save state (missing 0x046C chunk)");
|
|
269
|
+
|
|
270
|
+
const bytes = new Uint8Array(buffer);
|
|
271
|
+
const view = new DataView(buffer);
|
|
272
|
+
const chunks = parseChunks(bytes, view);
|
|
273
|
+
|
|
274
|
+
// Validate that required chunks are present (BeebEmID is guaranteed by isUefSnapshot above)
|
|
275
|
+
if (!chunks.has(ChunkId.Cpu6502)) throw new Error("BeebEm UEF save state missing CPU chunk (0x0460)");
|
|
276
|
+
if (!chunks.has(ChunkId.MainRam)) throw new Error("BeebEm UEF save state missing main RAM chunk (0x0462)");
|
|
277
|
+
|
|
278
|
+
// ── Model (from EmuState chunk 0x046A) ──────────────────────────────
|
|
279
|
+
// BeebEm Model enum: 0=B, 1=IntegraB, 2=BPlus, 3=Master128, 4=MasterET
|
|
280
|
+
let modelName = "B";
|
|
281
|
+
if (chunks.has(ChunkId.EmuState)) {
|
|
282
|
+
const emuData = chunks.get(ChunkId.EmuState)[0];
|
|
283
|
+
const machineType = emuData[0];
|
|
284
|
+
if (machineType === 3 || machineType === 4) {
|
|
285
|
+
modelName = "Master";
|
|
286
|
+
} else if (machineType === 2) {
|
|
287
|
+
modelName = "B"; // jsbeeb has no B+ model; BBC B is the closest match
|
|
288
|
+
}
|
|
289
|
+
// 0=B, 1=IntegraB → both treated as "B"
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── CPU (chunk 0x0460) ──────────────────────────────────────────────
|
|
293
|
+
// Layout: uint16 PC, uint8 A, X, Y, SP, PSR, uint32 TotalCycles(ignored),
|
|
294
|
+
// uint8 intStatus, uint8 NMIStatus, uint8 NMILock(ignored)
|
|
295
|
+
const d0460 = chunks.get(ChunkId.Cpu6502)[0];
|
|
296
|
+
if (d0460.length < 13) throw new Error(`CPU chunk too short: expected >= 13 bytes, got ${d0460.length}`);
|
|
297
|
+
const v0460 = new DataView(d0460.buffer, d0460.byteOffset);
|
|
298
|
+
const cpuState = {
|
|
299
|
+
pc: v0460.getUint16(0, true),
|
|
300
|
+
a: d0460[2],
|
|
301
|
+
x: d0460[3],
|
|
302
|
+
y: d0460[4],
|
|
303
|
+
s: d0460[5],
|
|
304
|
+
flags: d0460[6],
|
|
305
|
+
nmi: d0460[12],
|
|
306
|
+
fe30: 0,
|
|
307
|
+
fe34: 0,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// ── ROM registers (chunk 0x0461) ────────────────────────────────────
|
|
311
|
+
// Layout: uint8 PagedRomReg, uint8 ACCCON (and more for non-B models)
|
|
312
|
+
if (chunks.has(ChunkId.RomRegs)) {
|
|
313
|
+
const d = chunks.get(ChunkId.RomRegs)[0];
|
|
314
|
+
cpuState.fe30 = d[0] & 0x0f; // PagedRomReg: low nibble = ROM bank select
|
|
315
|
+
cpuState.fe34 = d[1]; // ACCCON
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── RAM ─────────────────────────────────────────────────────────────
|
|
319
|
+
// jsbeeb snapshot.state.ram is 128 KB (ramRomOs up to romOffset).
|
|
320
|
+
// Main RAM (chunk 0x0462): 32 KB at offset 0.
|
|
321
|
+
const ram = new Uint8Array(128 * 1024);
|
|
322
|
+
if (chunks.has(ChunkId.MainRam)) {
|
|
323
|
+
const mainRam = chunks.get(ChunkId.MainRam)[0];
|
|
324
|
+
// Defensive: chunk is defined as exactly 32 KB; clamp in case of a malformed file
|
|
325
|
+
ram.set(mainRam.slice(0, Math.min(32768, mainRam.length)));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Shadow RAM (chunk 0x0463, Master/B+) ─────────────────────────────
|
|
329
|
+
// BeebEm saves 32 KB (full shadow bank). jsbeeb's LYNNE region is 20 KB
|
|
330
|
+
// at ram[0xB000-0xFFFF], covering addresses 0x3000-0x7FFF when ACCCON X bit is set.
|
|
331
|
+
// See 6502.js writeAcccon: memLook[i] = bitX ? 0x8000 : 0 for pages 0x30-0x7F.
|
|
332
|
+
if (chunks.has(ChunkId.ShadowRam)) {
|
|
333
|
+
const shadowData = chunks.get(ChunkId.ShadowRam)[0];
|
|
334
|
+
if (shadowData.length >= 0x8000) {
|
|
335
|
+
// Full 32 KB shadow bank - extract LYNNE region (file offsets 0x3000-0x7FFF)
|
|
336
|
+
ram.set(shadowData.slice(0x3000, 0x8000), 0xb000);
|
|
337
|
+
} else if (shadowData.length >= 0x5000) {
|
|
338
|
+
// 20 KB LYNNE-only dump
|
|
339
|
+
ram.set(shadowData.slice(0, 0x5000), 0xb000);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Private RAM (chunk 0x0464, Master) ──────────────────────────────
|
|
344
|
+
// 12 KB: 4 KB ANDY (ram[0x8000-0x8FFF]) + 8 KB HAZEL (ram[0x9000-0xAFFF]).
|
|
345
|
+
if (chunks.has(ChunkId.PrivateRam)) {
|
|
346
|
+
const privData = chunks.get(ChunkId.PrivateRam)[0];
|
|
347
|
+
ram.set(privData.slice(0, Math.min(0x3000, privData.length)), 0x8000);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Sideways RAM (chunk 0x0466) ─────────────────────────────────────
|
|
351
|
+
// Each chunk: uint8 bank_number + 16384 bytes of data.
|
|
352
|
+
// BeebEm only saves sideways RAM banks, not the actual ROMs (BASIC, DFS, OS).
|
|
353
|
+
// We must NOT pass a roms array to restoreState because that would overwrite
|
|
354
|
+
// all 16 ROM banks (including the ROMs jsbeeb has already loaded) with zeros.
|
|
355
|
+
// Instead, we record which banks have sideways RAM data so restoreState can
|
|
356
|
+
// selectively overwrite only those banks.
|
|
357
|
+
let swRamBanks = null;
|
|
358
|
+
if (chunks.has(ChunkId.SwRam)) {
|
|
359
|
+
swRamBanks = {};
|
|
360
|
+
for (const d of chunks.get(ChunkId.SwRam)) {
|
|
361
|
+
if (d.length >= 1 + 16384) {
|
|
362
|
+
const bank = d[0] & 0x0f;
|
|
363
|
+
swRamBanks[bank] = d.slice(1, 1 + 16384);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── VIA (chunk 0x0467, one per VIA) ─────────────────────────────────
|
|
369
|
+
// sysvia default: IC32 = 0xff (all outputs open), keyboard not scanning
|
|
370
|
+
let sysvia = defaultViaState(0xff);
|
|
371
|
+
let uservia = defaultViaState(undefined);
|
|
372
|
+
if (chunks.has(ChunkId.Via)) {
|
|
373
|
+
for (const d of chunks.get(ChunkId.Via)) {
|
|
374
|
+
const { viaType, state } = convertViaChunk(d);
|
|
375
|
+
if (viaType === 0) sysvia = state;
|
|
376
|
+
else uservia = state;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Video (chunk 0x0468) ─────────────────────────────────────────────
|
|
381
|
+
// Layout: 18 CRTC regs, 1 ULA ctrl, 16 ULA palette
|
|
382
|
+
let ulaControl = 0x9c; // mode 7 (sensible default)
|
|
383
|
+
let ulaPalette = new Uint8Array(16);
|
|
384
|
+
let crtcRegs = new Uint8Array(18);
|
|
385
|
+
if (chunks.has(ChunkId.Video)) {
|
|
386
|
+
const d = chunks.get(ChunkId.Video)[0];
|
|
387
|
+
if (d.length >= 35) {
|
|
388
|
+
crtcRegs = d.slice(0, 18);
|
|
389
|
+
ulaControl = d[18];
|
|
390
|
+
for (let i = 0; i < 16; i++) ulaPalette[i] = d[19 + i];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const video = buildVideoState(ulaControl, ulaPalette, crtcRegs);
|
|
394
|
+
|
|
395
|
+
// ── Sound (chunk 0x046B) ─────────────────────────────────────────────
|
|
396
|
+
const soundData = chunks.has(ChunkId.Sound) ? chunks.get(ChunkId.Sound)[0] : null;
|
|
397
|
+
const soundChip = convertSoundChunk(soundData);
|
|
398
|
+
|
|
399
|
+
const snapshot = buildSnapshot("beebem-uef", modelName, cpuState, ram, null, sysvia, uservia, video, soundChip);
|
|
400
|
+
if (swRamBanks) snapshot.state.swRamBanks = swRamBanks;
|
|
401
|
+
return snapshot;
|
|
402
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -1,6 +1,132 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
// Minimal ZIP extractor using native DecompressionStream for deflate.
|
|
3
|
+
// Supports methods 0 (stored) and 8 (deflate); other methods (bzip2,
|
|
4
|
+
// lzma, etc.) will throw an error with the method number.
|
|
5
|
+
|
|
6
|
+
const ZipLocalHeaderSig = 0x04034b50;
|
|
7
|
+
const ZipCentralDirSig = 0x02014b50;
|
|
8
|
+
const ZipEocdSig = 0x06054b50;
|
|
9
|
+
const ZipMethodStored = 0;
|
|
10
|
+
const ZipMethodDeflate = 8;
|
|
11
|
+
|
|
12
|
+
function readU16(buf, off) {
|
|
13
|
+
return buf[off] | (buf[off + 1] << 8);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readU32(buf, off) {
|
|
17
|
+
return (buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24)) >>> 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isValidEocd(buf, off) {
|
|
21
|
+
const commentLen = readU16(buf, off + 20);
|
|
22
|
+
if (off + 22 + commentLen !== buf.length) return false;
|
|
23
|
+
const cdOff = readU32(buf, off + 16);
|
|
24
|
+
const cdSize = readU32(buf, off + 12);
|
|
25
|
+
return cdOff <= off && cdSize <= off - cdOff;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Find the End of Central Directory record by scanning backwards.
|
|
29
|
+
// EOCD is in the last 65557 bytes (22-byte fixed record + up to 65535-byte comment).
|
|
30
|
+
function findEocd(buf) {
|
|
31
|
+
const searchStart = Math.max(0, buf.length - 65557);
|
|
32
|
+
for (let i = buf.length - 22; i >= searchStart; i--) {
|
|
33
|
+
if (readU32(buf, i) === ZipEocdSig && isValidEocd(buf, i)) return i;
|
|
34
|
+
}
|
|
35
|
+
throw new Error("Not a ZIP file: EOCD not found");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Pipe data through a DecompressionStream and return the result.
|
|
39
|
+
// Starts the read loop before writing to avoid backpressure deadlock.
|
|
40
|
+
// On error, Node's DecompressionStream rejects multiple internal promises
|
|
41
|
+
// (write, close, and closed); we catch the write side to prevent unhandled
|
|
42
|
+
// rejections and let the error surface through the read side.
|
|
43
|
+
export async function decompress(data, format) {
|
|
44
|
+
const ds = new DecompressionStream(format);
|
|
45
|
+
const writer = ds.writable.getWriter();
|
|
46
|
+
const reader = ds.readable.getReader();
|
|
47
|
+
const chunks = [];
|
|
48
|
+
const readPromise = (async () => {
|
|
49
|
+
for (;;) {
|
|
50
|
+
const { done, value } = await reader.read();
|
|
51
|
+
if (done) break;
|
|
52
|
+
chunks.push(value);
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
const writePromise = (async () => {
|
|
56
|
+
await writer.write(data);
|
|
57
|
+
await writer.close();
|
|
58
|
+
})().catch(() => {
|
|
59
|
+
// Intentionally empty.
|
|
60
|
+
});
|
|
61
|
+
// Intentionally empty: Node's DecompressionStream rejects multiple
|
|
62
|
+
// promises on error (write, close, closed). The read side surfaces
|
|
63
|
+
// the same error with proper context — catching these just prevents
|
|
64
|
+
// unhandled rejections from the write-side promises.
|
|
65
|
+
writer.closed.catch(() => {});
|
|
66
|
+
await readPromise;
|
|
67
|
+
await writePromise;
|
|
68
|
+
if (chunks.length === 1) return chunks[0];
|
|
69
|
+
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
|
70
|
+
const out = new Uint8Array(totalLen);
|
|
71
|
+
let offset = 0;
|
|
72
|
+
for (const chunk of chunks) {
|
|
73
|
+
out.set(chunk, offset);
|
|
74
|
+
offset += chunk.length;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Extract all files from a ZIP archive. Returns {filename: Uint8Array}.
|
|
80
|
+
async function unzip(buf) {
|
|
81
|
+
if (!(buf instanceof Uint8Array)) buf = new Uint8Array(buf);
|
|
82
|
+
const eocdOff = findEocd(buf);
|
|
83
|
+
const cdOff = readU32(buf, eocdOff + 16);
|
|
84
|
+
const cdCount = readU16(buf, eocdOff + 10);
|
|
85
|
+
const decoder = new TextDecoder();
|
|
86
|
+
|
|
87
|
+
const files = Object.create(null);
|
|
88
|
+
let pos = cdOff;
|
|
89
|
+
for (let i = 0; i < cdCount; i++) {
|
|
90
|
+
if (pos + 46 > buf.length || readU32(buf, pos) !== ZipCentralDirSig)
|
|
91
|
+
throw new Error("Bad central directory entry");
|
|
92
|
+
const flags = readU16(buf, pos + 8);
|
|
93
|
+
if (flags & 0x0001) throw new Error("Encrypted ZIP entries are not supported");
|
|
94
|
+
const method = readU16(buf, pos + 10);
|
|
95
|
+
const compressedSize = readU32(buf, pos + 20);
|
|
96
|
+
const nameLen = readU16(buf, pos + 28);
|
|
97
|
+
const extraLen = readU16(buf, pos + 30);
|
|
98
|
+
const commentLen = readU16(buf, pos + 32);
|
|
99
|
+
const localHeaderOff = readU32(buf, pos + 42);
|
|
100
|
+
const name = decoder.decode(buf.subarray(pos + 46, pos + 46 + nameLen));
|
|
101
|
+
pos += 46 + nameLen + extraLen + commentLen;
|
|
102
|
+
|
|
103
|
+
// Read local file header to find actual data offset.
|
|
104
|
+
if (readU32(buf, localHeaderOff) !== ZipLocalHeaderSig) throw new Error(`Bad local file header for ${name}`);
|
|
105
|
+
const localNameLen = readU16(buf, localHeaderOff + 26);
|
|
106
|
+
const localExtraLen = readU16(buf, localHeaderOff + 28);
|
|
107
|
+
const dataOff = localHeaderOff + 30 + localNameLen + localExtraLen;
|
|
108
|
+
const dataEnd = dataOff + compressedSize;
|
|
109
|
+
if (dataEnd > buf.length) throw new Error(`Truncated ZIP entry data for ${name}`);
|
|
110
|
+
const raw = buf.subarray(dataOff, dataEnd);
|
|
111
|
+
|
|
112
|
+
if (method === ZipMethodStored) {
|
|
113
|
+
files[name] = raw.slice();
|
|
114
|
+
} else if (method === ZipMethodDeflate) {
|
|
115
|
+
files[name] = await decompress(raw, "deflate-raw");
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error(`Unsupported ZIP compression method ${method} for ${name}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return files;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function debounce(fn, wait) {
|
|
124
|
+
let timeout;
|
|
125
|
+
return function (...args) {
|
|
126
|
+
clearTimeout(timeout);
|
|
127
|
+
timeout = setTimeout(() => fn.apply(this, args), wait);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
4
130
|
|
|
5
131
|
export const runningInNode = typeof window === "undefined";
|
|
6
132
|
|
|
@@ -907,29 +1033,34 @@ export function readFloat32(data, offset) {
|
|
|
907
1033
|
return tempBufF32[0];
|
|
908
1034
|
}
|
|
909
1035
|
|
|
910
|
-
export function ungzip(data) {
|
|
1036
|
+
export async function ungzip(data) {
|
|
911
1037
|
try {
|
|
912
|
-
return
|
|
913
|
-
} catch (
|
|
914
|
-
throw new Error("Unable to ungzip: " +
|
|
1038
|
+
return await decompress(data, "gzip");
|
|
1039
|
+
} catch (cause) {
|
|
1040
|
+
throw new Error("Unable to ungzip: " + (cause.message || cause), { cause });
|
|
915
1041
|
}
|
|
916
1042
|
}
|
|
917
1043
|
|
|
918
1044
|
export class DataStream {
|
|
919
|
-
constructor(name, data
|
|
1045
|
+
constructor(name, data) {
|
|
920
1046
|
this.name = name;
|
|
921
1047
|
this.pos = 0;
|
|
922
1048
|
this.data = stringToUint8Array(data);
|
|
923
|
-
if (!dontUnzip && this.data && this.data.length > 4 && this.data[0] === 0x1f && this.data[1] === 0x8b) {
|
|
924
|
-
console.log("Ungzipping " + name);
|
|
925
|
-
this.data = ungzip(this.data);
|
|
926
|
-
}
|
|
927
1049
|
if (!this.data) {
|
|
928
1050
|
throw new Error("No data in " + name);
|
|
929
1051
|
}
|
|
930
1052
|
this.end = this.data.length;
|
|
931
1053
|
}
|
|
932
1054
|
|
|
1055
|
+
static async create(name, data) {
|
|
1056
|
+
const raw = stringToUint8Array(data);
|
|
1057
|
+
if (raw && raw.length > 4 && raw[0] === 0x1f && raw[1] === 0x8b) {
|
|
1058
|
+
console.log("Ungzipping " + name);
|
|
1059
|
+
data = await ungzip(raw);
|
|
1060
|
+
}
|
|
1061
|
+
return new DataStream(name, data);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
933
1064
|
bytesLeft() {
|
|
934
1065
|
return this.end - this.pos;
|
|
935
1066
|
}
|
|
@@ -1014,12 +1145,12 @@ const knownRomExtensions = {
|
|
|
1014
1145
|
rom: true,
|
|
1015
1146
|
};
|
|
1016
1147
|
|
|
1017
|
-
function unzipImage(data, knownExtensions) {
|
|
1148
|
+
async function unzipImage(data, knownExtensions) {
|
|
1018
1149
|
console.log("Attempting to unzip");
|
|
1019
1150
|
|
|
1020
1151
|
let files;
|
|
1021
1152
|
try {
|
|
1022
|
-
files =
|
|
1153
|
+
files = await unzip(data);
|
|
1023
1154
|
} catch (e) {
|
|
1024
1155
|
throw new Error("Error unzipping " + e.message, { cause: e });
|
|
1025
1156
|
}
|
|
@@ -1049,11 +1180,11 @@ function unzipImage(data, knownExtensions) {
|
|
|
1049
1180
|
return { data: uncompressed, name: loadedFile };
|
|
1050
1181
|
}
|
|
1051
1182
|
|
|
1052
|
-
export function unzipDiscImage(data) {
|
|
1183
|
+
export async function unzipDiscImage(data) {
|
|
1053
1184
|
return unzipImage(data, knownDiscExtensions);
|
|
1054
1185
|
}
|
|
1055
1186
|
|
|
1056
|
-
export function unzipRomImage(data) {
|
|
1187
|
+
export async function unzipRomImage(data) {
|
|
1057
1188
|
return unzipImage(data, knownRomExtensions);
|
|
1058
1189
|
}
|
|
1059
1190
|
|