jsbeeb 1.5.0 → 1.6.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,681 @@
1
+ "use strict";
2
+
3
+ // B-em snapshot format parser (versions 1 and 3).
4
+ // v1 (BEMSNAP1): Fixed-size 327,885 byte packed struct. Reference: beebjit state.c
5
+ // v3 (BEMSNAP3): Section-based with key+size headers, zlib-compressed memory. Reference: b-em savestate.c
6
+
7
+ const BemV1Size = 327885;
8
+
9
+ // v1 struct offsets
10
+ const V1Off = {
11
+ signature: 0,
12
+ model: 8,
13
+ a: 9,
14
+ x: 10,
15
+ y: 11,
16
+ flags: 12,
17
+ s: 13,
18
+ pc: 14,
19
+ nmi: 16,
20
+ interrupt: 17,
21
+ cycles: 18,
22
+ fe30: 22,
23
+ fe34: 23,
24
+ ram: 24,
25
+ rom: 24 + 65536,
26
+ sysvia: 24 + 65536 + 262144,
27
+ };
28
+
29
+ /**
30
+ * Check if an ArrayBuffer looks like a b-em snapshot (any version).
31
+ * @param {ArrayBuffer} buffer
32
+ * @returns {boolean}
33
+ */
34
+ export function isBemSnapshot(buffer) {
35
+ if (buffer.byteLength < 8) return false;
36
+ const sig = String.fromCharCode(...new Uint8Array(buffer, 0, 7));
37
+ return sig === "BEMSNAP";
38
+ }
39
+
40
+ /**
41
+ * Parse a b-em snapshot (v1 or v3) into a jsbeeb snapshot object.
42
+ * @param {ArrayBuffer} buffer
43
+ * @returns {object} jsbeeb snapshot
44
+ */
45
+ export async function parseBemSnapshot(buffer) {
46
+ if (buffer.byteLength < 8) throw new Error("File too small to be a b-em snapshot");
47
+ const bytes = new Uint8Array(buffer);
48
+ const sig = String.fromCharCode(...bytes.slice(0, 8));
49
+
50
+ if (sig === "BEMSNAP1") return parseBemV1(buffer);
51
+ if (sig === "BEMSNAP3") return parseBemV3(buffer);
52
+ throw new Error(`Unsupported b-em snapshot version: "${sig}"`);
53
+ }
54
+
55
+ // ── Shared helpers ──────────────────────────────────────────────────
56
+
57
+ function readViaFromBytes(data, offset) {
58
+ const view = new DataView(data.buffer, data.byteOffset + offset);
59
+ return {
60
+ ora: data[offset],
61
+ orb: data[offset + 1],
62
+ ira: data[offset + 2],
63
+ irb: data[offset + 3],
64
+ // +4, +5 are port read values (ignored on load, matching b-em via_loadstate)
65
+ ddra: data[offset + 6],
66
+ ddrb: data[offset + 7],
67
+ sr: data[offset + 8],
68
+ acr: data[offset + 9],
69
+ pcr: data[offset + 10],
70
+ ifr: data[offset + 11],
71
+ ier: data[offset + 12],
72
+ t1l: view.getInt32(13, true),
73
+ t2l: view.getInt32(17, true),
74
+ t1c: view.getInt32(21, true),
75
+ t2c: view.getInt32(25, true),
76
+ t1hit: data[offset + 29],
77
+ t2hit: data[offset + 30],
78
+ ca1: data[offset + 31],
79
+ ca2: data[offset + 32],
80
+ };
81
+ }
82
+
83
+ const ViaDataSize = 33; // 13 bytes + 4*4 timer ints + 4 booleans
84
+
85
+ function readCpuFromBytes(data) {
86
+ return {
87
+ a: data[0],
88
+ x: data[1],
89
+ y: data[2],
90
+ flags: data[3],
91
+ s: data[4],
92
+ pc: data[5] | (data[6] << 8),
93
+ nmi: data[7],
94
+ interrupt: data[8],
95
+ cycles: data[9] | (data[10] << 8) | (data[11] << 16) | (data[12] << 24),
96
+ };
97
+ }
98
+
99
+ function convertViaState(bemVia, ic32) {
100
+ const result = {
101
+ ora: bemVia.ora,
102
+ orb: bemVia.orb,
103
+ ira: bemVia.ira,
104
+ irb: bemVia.irb,
105
+ ddra: bemVia.ddra,
106
+ ddrb: bemVia.ddrb,
107
+ sr: bemVia.sr,
108
+ acr: bemVia.acr,
109
+ pcr: bemVia.pcr,
110
+ ifr: bemVia.ifr,
111
+ ier: bemVia.ier,
112
+ t1l: bemVia.t1l,
113
+ t2l: bemVia.t2l,
114
+ t1c: bemVia.t1c,
115
+ t2c: bemVia.t2c,
116
+ t1hit: !!bemVia.t1hit,
117
+ t2hit: !!bemVia.t2hit,
118
+ portapins: 0xff,
119
+ portbpins: 0xff,
120
+ ca1: !!bemVia.ca1,
121
+ ca2: !!bemVia.ca2,
122
+ cb1: false,
123
+ cb2: false,
124
+ justhit: 0,
125
+ t1_pb7: (bemVia.orb >> 7) & 1,
126
+ lastPolltime: 0,
127
+ taskOffset: 1,
128
+ };
129
+ if (ic32 !== undefined) {
130
+ result.IC32 = ic32;
131
+ result.capsLockLight = !(ic32 & 0x40);
132
+ result.shiftLockLight = !(ic32 & 0x80);
133
+ }
134
+ return result;
135
+ }
136
+
137
+ // Volume lookup table matching soundchip.js
138
+ const volumeTable = new Float32Array(16);
139
+ (() => {
140
+ let f = 1.0;
141
+ for (let i = 0; i < 15; ++i) {
142
+ volumeTable[i] = f / 4;
143
+ f *= Math.pow(10, -0.1);
144
+ }
145
+ volumeTable[15] = 0;
146
+ })();
147
+
148
+ function convertSoundState(snLatch, snCount, snStat, snVol, snNoise, snShift) {
149
+ const registers = new Uint16Array(4);
150
+ const counter = new Float32Array(4);
151
+ const outputBit = [false, false, false, false];
152
+ const volume = new Float32Array(4);
153
+
154
+ for (let i = 0; i < 4; ++i) {
155
+ const snChannel = 3 - i;
156
+ let period = snLatch[snChannel] >> 6;
157
+ let count = snCount[snChannel] >> 6;
158
+ if (i === 3) {
159
+ period >>= 1;
160
+ count >>= 1;
161
+ }
162
+ registers[i] = period;
163
+ counter[i] = count;
164
+ outputBit[i] = snStat[snChannel] < 16;
165
+ volume[i] = volumeTable[snVol[snChannel] & 0x0f];
166
+ }
167
+ registers[3] = snNoise & 0x07;
168
+
169
+ return {
170
+ registers,
171
+ counter,
172
+ outputBit,
173
+ volume,
174
+ lfsr: snShift,
175
+ latchedRegister: 0,
176
+ residual: 0,
177
+ sineOn: false,
178
+ sineStep: 0,
179
+ sineTime: 0,
180
+ };
181
+ }
182
+
183
+ function buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, crtcCounters) {
184
+ const regs = new Uint8Array(32);
185
+ regs.set(crtcRegs.slice(0, 18));
186
+ const actualPal = new Uint8Array(16);
187
+ for (let i = 0; i < 16; i++) actualPal[i] = ulaPalette[i] & 0x0f;
188
+
189
+ // Use NULA collook if provided, otherwise use default BBC palette
190
+ const collook =
191
+ nulaCollook ||
192
+ new Int32Array([
193
+ 0xff000000, 0xff0000ff, 0xff00ff00, 0xff00ffff, 0xffff0000, 0xffff00ff, 0xffffff00, 0xffffffff, 0xff000000,
194
+ 0xff0000ff, 0xff00ff00, 0xff00ffff, 0xffff0000, 0xffff00ff, 0xffffff00, 0xffffffff,
195
+ ]);
196
+
197
+ // Compute ulaPal from actualPal + collook, matching jsbeeb's Ula._recomputeUlaPal
198
+ const flashEnabled = !!(ulaControl & 1);
199
+ const defaultFlash = new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1]);
200
+ const flash = defaultFlash;
201
+ const ulaPal = new Int32Array(16);
202
+ for (let i = 0; i < 16; i++) {
203
+ const palVal = actualPal[i];
204
+ let colour = collook[(palVal & 0xf) ^ 7];
205
+ if (palVal & 8 && flashEnabled && flash[(palVal & 7) ^ 7]) {
206
+ colour = collook[palVal & 0xf];
207
+ }
208
+ ulaPal[i] = colour;
209
+ }
210
+
211
+ return {
212
+ regs,
213
+ bitmapX: 0,
214
+ bitmapY: 0,
215
+ oddClock: false,
216
+ frameCount: 0,
217
+ doEvenFrameLogic: false,
218
+ isEvenRender: true,
219
+ lastRenderWasEven: false,
220
+ firstScanline: true,
221
+ inHSync: false,
222
+ inVSync: false,
223
+ hadVSyncThisRow: false,
224
+ checkVertAdjust: false,
225
+ endOfMainLatched: false,
226
+ endOfVertAdjustLatched: false,
227
+ endOfFrameLatched: false,
228
+ inVertAdjust: false,
229
+ inDummyRaster: false,
230
+ hpulseWidth: regs[3] & 0x0f,
231
+ vpulseWidth: (regs[3] & 0xf0) >>> 4,
232
+ hpulseCounter: 0,
233
+ vpulseCounter: 0,
234
+ dispEnabled: 0x3f,
235
+ horizCounter: crtcCounters ? crtcCounters.hc : 0,
236
+ vertCounter: crtcCounters ? crtcCounters.vc : 0,
237
+ scanlineCounter: crtcCounters ? crtcCounters.sc : 0,
238
+ vertAdjustCounter: 0,
239
+ addr: crtcCounters ? crtcCounters.ma : (regs[13] | (regs[12] << 8)) & 0x3fff,
240
+ lineStartAddr: crtcCounters ? crtcCounters.maback : (regs[13] | (regs[12] << 8)) & 0x3fff,
241
+ nextLineStartAddr: crtcCounters ? crtcCounters.maback : (regs[13] | (regs[12] << 8)) & 0x3fff,
242
+ ulactrl: ulaControl,
243
+ pixelsPerChar: ulaControl & 0x10 ? 8 : 16,
244
+ halfClock: !(ulaControl & 0x10),
245
+ ulaMode: (ulaControl >>> 2) & 3,
246
+ teletextMode: !!(ulaControl & 2),
247
+ displayEnableSkew: Math.min((regs[8] & 0x30) >>> 4, 2),
248
+ ulaPal,
249
+ actualPal,
250
+ cursorOn: false,
251
+ cursorOff: false,
252
+ cursorOnThisFrame: false,
253
+ cursorDrawIndex: 0,
254
+ cursorPos: (regs[15] | (regs[14] << 8)) & 0x3fff,
255
+ interlacedSyncAndVideo: (regs[8] & 3) === 3,
256
+ screenSubtract: 0,
257
+ ula: {
258
+ collook: collook.slice(),
259
+ flash: new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1]),
260
+ paletteWriteFlag: false,
261
+ paletteFirstByte: 0,
262
+ paletteMode: 0,
263
+ horizontalOffset: 0,
264
+ leftBlank: 0,
265
+ disabled: false,
266
+ attributeMode: 0,
267
+ attributeText: 0,
268
+ },
269
+ crtc: { curReg: 0 },
270
+ teletext: {
271
+ prevCol: 0,
272
+ col: 7,
273
+ bg: 0,
274
+ sep: false,
275
+ dbl: false,
276
+ oldDbl: false,
277
+ secondHalfOfDouble: false,
278
+ wasDbl: false,
279
+ gfx: false,
280
+ flash: false,
281
+ flashOn: false,
282
+ flashTime: 0,
283
+ heldChar: 0,
284
+ holdChar: false,
285
+ dataQueue: [0, 0, 0, 0],
286
+ scanlineCounter: 0,
287
+ levelDEW: false,
288
+ levelDISPTMG: false,
289
+ levelRA0: false,
290
+ nextGlyphs: "normal",
291
+ curGlyphs: "normal",
292
+ heldGlyphs: "normal",
293
+ },
294
+ };
295
+ }
296
+
297
+ const DefaultAcia = {
298
+ sr: 0x02,
299
+ cr: 0x00,
300
+ dr: 0x00,
301
+ rs423Selected: false,
302
+ motorOn: false,
303
+ tapeCarrierCount: 0,
304
+ tapeDcdLineLevel: false,
305
+ hadDcdHigh: false,
306
+ serialReceiveRate: 19200,
307
+ serialReceiveCyclesPerByte: 0,
308
+ txCompleteTaskOffset: null,
309
+ runTapeTaskOffset: null,
310
+ runRs423TaskOffset: null,
311
+ };
312
+
313
+ const DefaultAdc = { status: 0x40, low: 0x00, high: 0x00, taskOffset: null };
314
+
315
+ function buildSnapshot(modelName, cpuState, ram, roms, sysvia, uservia, video, soundChip) {
316
+ return {
317
+ format: "jsbeeb-snapshot",
318
+ version: 1,
319
+ model: modelName,
320
+ timestamp: new Date().toISOString(),
321
+ importedFrom: "b-em",
322
+ state: {
323
+ a: cpuState.a,
324
+ x: cpuState.x,
325
+ y: cpuState.y,
326
+ s: cpuState.s,
327
+ pc: cpuState.pc,
328
+ p: cpuState.flags | 0x30,
329
+ nmiLevel: !!cpuState.nmi,
330
+ nmiEdge: false,
331
+ halted: false,
332
+ takeInt: false,
333
+ romsel: cpuState.fe30 ?? 0,
334
+ acccon: cpuState.fe34 ?? 0,
335
+ videoDisplayPage: 0,
336
+ currentCycles: 0,
337
+ targetCycles: 0,
338
+ cycleSeconds: 0,
339
+ peripheralCycles: 0,
340
+ videoCycles: 0,
341
+ music5000PageSel: 0,
342
+ ram,
343
+ roms,
344
+ scheduler: { epoch: 0 },
345
+ sysvia,
346
+ uservia,
347
+ video,
348
+ soundChip,
349
+ acia: { ...DefaultAcia },
350
+ adc: { ...DefaultAdc },
351
+ },
352
+ };
353
+ }
354
+
355
+ // ── V1 parser (BEMSNAP1, fixed struct) ──────────────────────────────
356
+
357
+ function parseBemV1(buffer) {
358
+ if (buffer.byteLength !== BemV1Size) {
359
+ throw new Error(`Invalid BEM v1 snapshot size: expected ${BemV1Size}, got ${buffer.byteLength}`);
360
+ }
361
+ const bytes = new Uint8Array(buffer);
362
+ const view = new DataView(buffer);
363
+
364
+ const bemModel = bytes[V1Off.model];
365
+ if (bemModel !== 3 && bemModel !== 4) {
366
+ throw new Error(`Unsupported BEM v1 model: ${bemModel} (only BBC Model B supported)`);
367
+ }
368
+
369
+ const cpuState = {
370
+ ...readCpuFromBytes(bytes.slice(V1Off.a)),
371
+ fe30: bytes[V1Off.fe30],
372
+ fe34: bytes[V1Off.fe34],
373
+ };
374
+
375
+ const ram = new Uint8Array(128 * 1024);
376
+ ram.set(bytes.slice(V1Off.ram, V1Off.ram + 65536));
377
+ const roms = bytes.slice(V1Off.rom, V1Off.rom + 262144);
378
+
379
+ const sysVia = readViaFromBytes(bytes, V1Off.sysvia);
380
+ const sysViaIC32 = bytes[V1Off.sysvia + ViaDataSize];
381
+ const userVia = readViaFromBytes(bytes, V1Off.sysvia + ViaDataSize + 1);
382
+
383
+ const ulaOff = V1Off.sysvia + ViaDataSize + 1 + ViaDataSize;
384
+ const ulaControl = bytes[ulaOff];
385
+ const ulaPalette = bytes.slice(ulaOff + 1, ulaOff + 17);
386
+ const crtcOff = ulaOff + 17;
387
+ const crtcRegs = bytes.slice(crtcOff, crtcOff + 18);
388
+
389
+ const soundOff = crtcOff + 18 + 7 + 4 + 1 + 4;
390
+ const snLatch = [],
391
+ snCount = [],
392
+ snStat = [],
393
+ snVol = [];
394
+ for (let i = 0; i < 4; i++) {
395
+ snLatch.push(view.getUint32(soundOff + i * 4, true));
396
+ snCount.push(view.getUint32(soundOff + 16 + i * 4, true));
397
+ snStat.push(view.getUint32(soundOff + 32 + i * 4, true));
398
+ snVol.push(bytes[soundOff + 48 + i]);
399
+ }
400
+ const snNoise = bytes[soundOff + 52];
401
+ const snShift = view.getUint16(soundOff + 53, true);
402
+
403
+ return buildSnapshot(
404
+ "B",
405
+ cpuState,
406
+ ram,
407
+ roms,
408
+ convertViaState(sysVia, sysViaIC32),
409
+ convertViaState(userVia),
410
+ buildVideoState(ulaControl, ulaPalette, crtcRegs),
411
+ convertSoundState(snLatch, snCount, snStat, snVol, snNoise, snShift),
412
+ );
413
+ }
414
+
415
+ // ── V3 parser (BEMSNAP3, section-based) ─────────────────────────────
416
+
417
+ // Variable-length integer encoding used by b-em v3
418
+ function readVar(data, pos) {
419
+ let value = 0;
420
+ let shift = 0;
421
+ while (pos.offset < data.length) {
422
+ const byte = data[pos.offset++];
423
+ value |= (byte & 0x7f) << shift;
424
+ if (byte & 0x80) break;
425
+ shift += 7;
426
+ }
427
+ return value;
428
+ }
429
+
430
+ function readString(data, pos) {
431
+ const len = readVar(data, pos);
432
+ const str = String.fromCharCode(...data.slice(pos.offset, pos.offset + len));
433
+ pos.offset += len;
434
+ return str;
435
+ }
436
+
437
+ async function inflateRaw(compressedData) {
438
+ // Use DecompressionStream (available in modern browsers and Node 18+)
439
+ if (typeof DecompressionStream !== "undefined") {
440
+ const ds = new DecompressionStream("deflate");
441
+ const writer = ds.writable.getWriter();
442
+ const reader = ds.readable.getReader();
443
+ const chunks = [];
444
+
445
+ // Start reading before writing to avoid backpressure deadlock
446
+ const readPromise = (async () => {
447
+ for (;;) {
448
+ const { done, value } = await reader.read();
449
+ if (done) break;
450
+ chunks.push(value);
451
+ }
452
+ })();
453
+
454
+ await writer.write(compressedData);
455
+ await writer.close();
456
+ await readPromise;
457
+
458
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
459
+ const result = new Uint8Array(totalLength);
460
+ let offset = 0;
461
+ for (const chunk of chunks) {
462
+ result.set(chunk, offset);
463
+ offset += chunk.length;
464
+ }
465
+ return result;
466
+ }
467
+ throw new Error("Zlib decompression not available (need DecompressionStream API)");
468
+ }
469
+
470
+ function parseBemV3(buffer) {
471
+ const bytes = new Uint8Array(buffer);
472
+ let offset = 8; // Skip "BEMSNAP3" signature
473
+
474
+ const sections = {};
475
+ while (offset < bytes.length) {
476
+ let key = bytes[offset];
477
+ let size = bytes[offset + 1] | (bytes[offset + 2] << 8);
478
+ let headerSize = 3;
479
+
480
+ if (key & 0x80) {
481
+ // Extended size (4 bytes) for compressed sections
482
+ key &= 0x7f;
483
+ size |= (bytes[offset + 3] << 16) | (bytes[offset + 4] << 24);
484
+ headerSize = 5;
485
+ }
486
+
487
+ const sectionData = bytes.slice(offset + headerSize, offset + headerSize + size);
488
+ const keyChar = String.fromCharCode(key);
489
+ sections[keyChar] = { data: sectionData, compressed: headerSize === 5 };
490
+ offset += headerSize + size;
491
+ }
492
+
493
+ // Parse model section to determine jsbeeb model name.
494
+ // Use jsbeeb synonyms (from models.js) so findModel() resolves them.
495
+ let modelName = "B";
496
+ if (sections["m"]) {
497
+ const pos = { offset: 0 };
498
+ const data = sections["m"].data;
499
+ readVar(data, pos); // curmodel index (skip)
500
+ const name = readString(data, pos);
501
+ if (name.includes("Master")) {
502
+ if (name.includes("ADFS")) modelName = "MasterADFS";
503
+ else if (name.includes("ANFS")) modelName = "MasterANFS";
504
+ else modelName = "Master";
505
+ } else if (name.includes("1770")) {
506
+ if (name.includes("ADFS")) modelName = "B1770A";
507
+ else modelName = "B1770";
508
+ } else {
509
+ modelName = "B";
510
+ }
511
+ }
512
+
513
+ // Parse CPU
514
+ let cpuState = { a: 0, x: 0, y: 0, flags: 0x30, s: 0xff, pc: 0, nmi: 0, interrupt: 0, cycles: 0 };
515
+ if (sections["6"]) {
516
+ cpuState = readCpuFromBytes(sections["6"].data);
517
+ }
518
+
519
+ // Parse memory (zlib-compressed in v3)
520
+ const ram = new Uint8Array(128 * 1024);
521
+ let roms = null;
522
+ const memSection = sections["M"];
523
+ if (memSection) {
524
+ // Memory is zlib-compressed; decompression is async.
525
+ // Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
526
+ return inflateRaw(memSection.data).then((memData) => {
527
+ cpuState.fe30 = memData[0];
528
+ cpuState.fe34 = memData[1];
529
+ const ramStart = 2;
530
+ const ramSize = 64 * 1024;
531
+ ram.set(memData.slice(ramStart, ramStart + ramSize));
532
+ const romStart = ramStart + ramSize;
533
+ if (memData.length > romStart) {
534
+ roms = memData.slice(romStart, romStart + 262144);
535
+ }
536
+ return finishV3Parse(modelName, cpuState, ram, roms, sections);
537
+ });
538
+ }
539
+ return finishV3Parse(modelName, cpuState, ram, roms, sections);
540
+ }
541
+
542
+ function finishV3Parse(modelName, cpuState, ram, roms, sections) {
543
+ // Parse system VIA
544
+ let sysvia = convertViaState(
545
+ {
546
+ ora: 0,
547
+ orb: 0,
548
+ ira: 0,
549
+ irb: 0,
550
+ ddra: 0,
551
+ ddrb: 0,
552
+ sr: 0,
553
+ acr: 0,
554
+ pcr: 0,
555
+ ifr: 0,
556
+ ier: 0,
557
+ t1l: 0x1fffe,
558
+ t2l: 0x1fffe,
559
+ t1c: 0x1fffe,
560
+ t2c: 0x1fffe,
561
+ t1hit: 1,
562
+ t2hit: 1,
563
+ ca1: 0,
564
+ ca2: 0,
565
+ },
566
+ 0,
567
+ );
568
+ if (sections["S"]) {
569
+ const data = sections["S"].data;
570
+ const via = readViaFromBytes(data, 0);
571
+ const ic32 = data.length > ViaDataSize ? data[ViaDataSize] : 0;
572
+ sysvia = convertViaState(via, ic32);
573
+ }
574
+
575
+ // Parse user VIA
576
+ let uservia = convertViaState({
577
+ ora: 0,
578
+ orb: 0,
579
+ ira: 0,
580
+ irb: 0,
581
+ ddra: 0,
582
+ ddrb: 0,
583
+ sr: 0,
584
+ acr: 0,
585
+ pcr: 0,
586
+ ifr: 0,
587
+ ier: 0,
588
+ t1l: 0x1fffe,
589
+ t2l: 0x1fffe,
590
+ t1c: 0x1fffe,
591
+ t2c: 0x1fffe,
592
+ t1hit: 1,
593
+ t2hit: 1,
594
+ ca1: 0,
595
+ ca2: 0,
596
+ });
597
+ if (sections["U"]) {
598
+ uservia = convertViaState(readViaFromBytes(sections["U"].data, 0));
599
+ }
600
+
601
+ // Parse Video ULA
602
+ // v3 section layout (97 bytes):
603
+ // 1: ula_ctrl
604
+ // 16: ula_palbak[16] (raw palette register values)
605
+ // 64: nula_collook[16] (4 bytes each: R, G, B, A in Allegro RGBA format)
606
+ // 1: nula_pal_write_flag
607
+ // 1: nula_pal_first_byte
608
+ // 8: nula_flash[8]
609
+ // 1: nula_palette_mode
610
+ // ... (more NULA state follows)
611
+ let ulaControl = 0;
612
+ let ulaPalette = new Uint8Array(16);
613
+ let nulaCollook = null;
614
+ if (sections["V"]) {
615
+ const data = sections["V"].data;
616
+ ulaControl = data[0];
617
+ ulaPalette = data.slice(1, 17);
618
+ // Parse NULA collook if section is large enough (v3 has 97 bytes)
619
+ if (data.length >= 81) {
620
+ nulaCollook = new Int32Array(16);
621
+ for (let c = 0; c < 16; c++) {
622
+ const off = 17 + c * 4;
623
+ // b-em stores as R, G, B, A (Allegro format)
624
+ // jsbeeb uses ABGR format (Uint32 on little-endian = canvas RGBA)
625
+ const r = data[off];
626
+ const g = data[off + 1];
627
+ const b = data[off + 2];
628
+ const a = data[off + 3];
629
+ nulaCollook[c] = (a << 24) | (b << 16) | (g << 8) | r;
630
+ }
631
+ }
632
+ }
633
+
634
+ // Parse CRTC (18 regs + optional 7 counter bytes: vc, sc, hc, ma_lo, ma_hi, maback_lo, maback_hi)
635
+ let crtcRegs = new Uint8Array(18);
636
+ let crtcCounters = null;
637
+ if (sections["C"]) {
638
+ const data = sections["C"].data;
639
+ crtcRegs = data.slice(0, 18);
640
+ if (data.length >= 25) {
641
+ crtcCounters = {
642
+ vc: data[18],
643
+ sc: data[19],
644
+ hc: data[20],
645
+ ma: data[21] | (data[22] << 8),
646
+ maback: data[23] | (data[24] << 8),
647
+ };
648
+ }
649
+ }
650
+
651
+ // Parse sound
652
+ let soundChip = convertSoundState([0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 1 << 14);
653
+ if (sections["s"]) {
654
+ const data = sections["s"].data;
655
+ const view = new DataView(data.buffer, data.byteOffset);
656
+ const snLatch = [],
657
+ snCount = [],
658
+ snStat = [],
659
+ snVol = [];
660
+ for (let i = 0; i < 4; i++) {
661
+ snLatch.push(view.getUint32(i * 4, true));
662
+ snCount.push(view.getUint32(16 + i * 4, true));
663
+ snStat.push(view.getUint32(32 + i * 4, true));
664
+ snVol.push(data[48 + i]);
665
+ }
666
+ const snNoise = data[52];
667
+ const snShift = view.getUint16(53, true);
668
+ soundChip = convertSoundState(snLatch, snCount, snStat, snVol, snNoise, snShift);
669
+ }
670
+
671
+ return buildSnapshot(
672
+ modelName,
673
+ cpuState,
674
+ ram,
675
+ roms,
676
+ sysvia,
677
+ uservia,
678
+ buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, crtcCounters),
679
+ soundChip,
680
+ );
681
+ }