jsbeeb 1.11.0 → 1.13.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,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/url-params.js CHANGED
@@ -240,6 +240,7 @@ export function processAutobootParams(parsedQuery) {
240
240
  export function guessModelFromHostname(hostname) {
241
241
  if (hostname.startsWith("bbc")) return "B-DFS1.2";
242
242
  if (hostname.startsWith("master")) return "Master";
243
+ if (hostname.startsWith("atom")) return "Atom";
243
244
  return "B-DFS1.2";
244
245
  }
245
246
 
@@ -247,10 +248,14 @@ export function guessModelFromHostname(hostname) {
247
248
  * Parse disc or tape images from the query parameters
248
249
  * @param {Object} parsedQuery - The query parameters
249
250
  * @returns {Object} Object containing disc and tape information
251
+ * - discImage: disc image URL (?disc= or ?disc1=)
252
+ * - secondDiscImage: second disc URL (?disc2=)
253
+ * - tapeImage: tape image URL (?tape=)
254
+ * - mmcImage: MMC/SD card image URL (?mmc=, Atom only)
250
255
  */
251
256
  export function parseMediaParams(parsedQuery) {
252
- const { disc, disc1, disc2, tape } = parsedQuery;
257
+ const { disc, disc1, disc2, tape, mmc } = parsedQuery;
253
258
  const discImage = disc || disc1;
254
259
 
255
- return { discImage, secondDiscImage: disc2, tapeImage: tape };
260
+ return { discImage, secondDiscImage: disc2, tapeImage: tape, mmcImage: mmc };
256
261
  }
package/src/utils.js CHANGED
@@ -55,7 +55,13 @@ export async function decompress(data, format) {
55
55
  const writePromise = (async () => {
56
56
  await writer.write(data);
57
57
  await writer.close();
58
- })().catch(() => {});
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.
59
65
  writer.closed.catch(() => {});
60
66
  await readPromise;
61
67
  await writePromise;
@@ -71,7 +77,7 @@ export async function decompress(data, format) {
71
77
  }
72
78
 
73
79
  // Extract all files from a ZIP archive. Returns {filename: Uint8Array}.
74
- async function unzip(buf) {
80
+ export async function unzip(buf) {
75
81
  if (!(buf instanceof Uint8Array)) buf = new Uint8Array(buf);
76
82
  const eocdOff = findEocd(buf);
77
83
  const cdOff = readU32(buf, eocdOff + 16);
@@ -114,6 +120,79 @@ async function unzip(buf) {
114
120
  return files;
115
121
  }
116
122
 
123
+ // Standard CRC-32/ISO-HDLC.
124
+ export function crc32(data) {
125
+ let crc = 0xffffffff;
126
+ for (let i = 0; i < data.length; ++i) {
127
+ crc ^= data[i];
128
+ for (let j = 0; j < 8; ++j) {
129
+ const doEor = crc & 1;
130
+ crc = crc >>> 1;
131
+ if (doEor) crc ^= 0xedb88320;
132
+ }
133
+ }
134
+ return ~crc;
135
+ }
136
+
137
+ // Create a ZIP blob from an array of {name: string, data: Uint8Array} entries.
138
+ // Uses stored method (no compression) with CRC-32 for maximum compatibility.
139
+ export function createZipBlob(files) {
140
+ const encoder = new TextEncoder();
141
+ const localHeaders = [];
142
+ const centralHeaders = [];
143
+ let offset = 0;
144
+
145
+ for (const { name, data } of files) {
146
+ const nameBytes = encoder.encode(name);
147
+ const crc = crc32(data);
148
+ // Local file header (30 + nameLen + data)
149
+ const local = new Uint8Array(30 + nameBytes.length + data.length);
150
+ const lv = new DataView(local.buffer);
151
+ lv.setUint32(0, 0x04034b50, true); // signature
152
+ lv.setUint16(4, 20, true); // version needed
153
+ lv.setUint16(8, 0, true); // method: stored
154
+ lv.setUint32(14, crc, true); // CRC-32
155
+ lv.setUint32(18, data.length, true); // compressed size
156
+ lv.setUint32(22, data.length, true); // uncompressed size
157
+ lv.setUint16(26, nameBytes.length, true); // name length
158
+ local.set(nameBytes, 30);
159
+ local.set(data, 30 + nameBytes.length);
160
+ localHeaders.push(local);
161
+
162
+ // Central directory entry (46 + nameLen)
163
+ const central = new Uint8Array(46 + nameBytes.length);
164
+ const cv = new DataView(central.buffer);
165
+ cv.setUint32(0, 0x02014b50, true); // signature
166
+ cv.setUint16(4, 20, true); // version made by
167
+ cv.setUint16(6, 20, true); // version needed
168
+ cv.setUint16(10, 0, true); // method: stored
169
+ cv.setUint32(16, crc, true); // CRC-32
170
+ cv.setUint32(20, data.length, true); // compressed size
171
+ cv.setUint32(24, data.length, true); // uncompressed size
172
+ cv.setUint16(28, nameBytes.length, true); // name length
173
+ cv.setUint32(42, offset, true); // local header offset
174
+ central.set(nameBytes, 46);
175
+ centralHeaders.push(central);
176
+
177
+ offset += local.length;
178
+ }
179
+
180
+ const cdOffset = offset;
181
+ let cdSize = 0;
182
+ for (const c of centralHeaders) cdSize += c.length;
183
+
184
+ // End of central directory (22 bytes)
185
+ const eocd = new Uint8Array(22);
186
+ const ev = new DataView(eocd.buffer);
187
+ ev.setUint32(0, 0x06054b50, true); // signature
188
+ ev.setUint16(8, files.length, true); // entries on this disc
189
+ ev.setUint16(10, files.length, true); // total entries
190
+ ev.setUint32(12, cdSize, true); // central directory size
191
+ ev.setUint32(16, cdOffset, true); // central directory offset
192
+
193
+ return new Blob([...localHeaders, ...centralHeaders, eocd], { type: "application/zip" });
194
+ }
195
+
117
196
  export function debounce(fn, wait) {
118
197
  let timeout;
119
198
  return function (...args) {