jsbeeb 1.13.0 → 1.14.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 CHANGED
@@ -167,8 +167,9 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
167
167
  - `autotype` - types whatever you put after. e.g. `&autotype=PRINT"Matt is cool"%0a` (return is URI escaped to `%0a`)
168
168
  - `embed` - Remove the margins around the screen, hide most navigation entries and make the page background
169
169
  transparent (intended for use when running within an iframe in a third-party site).
170
- - `cpuMultiplier=X` speeds up the CPU by a factor of `X`. May be fractional or below one to slow the CPU down. NB disc
171
- loads become unreliable with a too-slow CPU, and running too fast might cause the browser to hang.
170
+ - `cpuMultiplier=X` speeds up the CPU by a factor of `X` relative to the peripherals: video, sound and the VIAs keep
171
+ running at their real-world rates. May be fractional or below one to slow the CPU down. NB disc loads become
172
+ unreliable with a too-slow CPU, and running too fast might cause the browser to hang.
172
173
  - `sbLeft` / `sbRight` / `sbBottom` - a URL to place left of, right of, or below the cub monitor. The left and right
173
174
  should be around 648 high and the bottom image should be around 896 wide. Left and right wider than 300 will run into
174
175
  problems on smaller screens; bottom taller than 100 or so similarly.
package/package.json CHANGED
@@ -7,10 +7,10 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.13.0",
11
- "//": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
10
+ "version": "1.14.0",
11
+ "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
- "node": ">=22.12.0"
13
+ "node": ">=24.15.0"
14
14
  },
15
15
  "type": "module",
16
16
  "main": "./src/app/app.js",
@@ -27,33 +27,37 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@popperjs/core": "^2.11.8",
30
- "argparse": "^2.0.1",
30
+ "argparse": "^3.0.0",
31
31
  "bootstrap": "^5.3.8",
32
32
  "bootswatch": "^5.3.8",
33
- "sharp": "^0.34.5",
33
+ "sharp": "^0.35.3",
34
34
  "smoothie": "^1.36.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@eslint/js": "^10.0.1",
38
38
  "@vitest/coverage-v8": "^4.0.18",
39
- "eslint": "^10.2.0",
39
+ "eslint": "^10.7.0",
40
40
  "eslint-config-prettier": "^10.1.8",
41
- "eslint-plugin-prettier": "^5.5.5",
42
- "globals": "^17.5.0",
41
+ "eslint-plugin-prettier": "^5.5.6",
42
+ "globals": "^17.7.0",
43
43
  "husky": "^9.1.7",
44
- "jsdom": "^29.0.2",
45
- "lint-staged": "^16.4.0",
46
- "npm-run-all2": "^8.0.4",
47
- "pixelmatch": "^7.1.0",
48
- "prettier": "^3.8.2",
49
- "vite": "^8.0.8",
44
+ "jsdom": "^30.0.1",
45
+ "lint-staged": "^17.1.1",
46
+ "npm-run-all2": "^9.0.2",
47
+ "pixelmatch": "^7.2.0",
48
+ "prettier": "^3.9.6",
49
+ "vite": "^8.1.5",
50
50
  "vitest": "^4.0.10"
51
51
  },
52
52
  "optionalDependencies": {
53
- "electron": "^41.2.0",
54
- "electron-builder": "^26.8.1",
53
+ "electron": "^43.2.0",
54
+ "electron-builder": "^26.15.3",
55
55
  "electron-store": "^11.0.2"
56
56
  },
57
+ "//overrides": "brace-expansion forced to the patched version for GHSA-mh99-v99m-4gvg; the electron-builder dependency chain still pins vulnerable versions. Remove once `npm audit` is clean without it.",
58
+ "overrides": {
59
+ "brace-expansion": "^5.0.8"
60
+ },
57
61
  "license": "GPL-3.0-or-later",
58
62
  "build": {
59
63
  "appId": "org.godbolt.bbc",
@@ -108,5 +112,8 @@
108
112
  "prettier --write"
109
113
  ],
110
114
  "*.{md,json,yml,yaml,css,html}": "prettier --write"
115
+ },
116
+ "allowScripts": {
117
+ "electron-winstaller@5.4.0": true
111
118
  }
112
119
  }
package/src/6502.js CHANGED
@@ -15,6 +15,9 @@ import { AtomMMC2 } from "./mmc.js";
15
15
 
16
16
  const signExtend = utils.signExtend;
17
17
 
18
+ // Speed of the second processor relative to the host, as fitted to a Master Turbo.
19
+ export const DefaultTubeCpuMultiplier = 2;
20
+
18
21
  function _set(byte, mask, set) {
19
22
  return (byte & ~mask) | (set ? mask : 0);
20
23
  }
@@ -407,11 +410,11 @@ class Base6502 {
407
410
  }
408
411
 
409
412
  class Tube6502 extends Base6502 {
410
- constructor(model, cpu) {
413
+ constructor(model, cpu, { cpuMultiplier = DefaultTubeCpuMultiplier } = {}) {
411
414
  super(model, { cycleAccurate: false });
412
415
 
413
416
  this.cycles = 0;
414
- this.cpuMultiplier = 2;
417
+ this.cpuMultiplier = cpuMultiplier;
415
418
  this.romPaged = true;
416
419
  this.memory = new Uint8Array(65536);
417
420
  this.rom = new Uint8Array(4096);
@@ -424,6 +427,10 @@ class Tube6502 extends Base6502 {
424
427
  this.pc = this.readmem(0xfffc) | (this.readmem(0xfffd) << 8);
425
428
  this.p.i = true;
426
429
  this.s = (this.s - 3) & 0xff; // Simulate 3 dummy pushes during reset
430
+ // A latched NMI would otherwise survive the reset and be taken on the first instruction
431
+ // after it, defeating the empty R3 FIFO the ULA seeds for exactly that reason.
432
+ this._nmiLevel = this._nmiEdge = false;
433
+ this.takeInt = false;
427
434
  this.tube.reset(hard);
428
435
  }
429
436
 
@@ -482,6 +489,46 @@ class Tube6502 extends Base6502 {
482
489
  }
483
490
  }
484
491
 
492
+ snapshotState({ includeRoms = false } = {}) {
493
+ return {
494
+ a: this.a,
495
+ x: this.x,
496
+ y: this.y,
497
+ s: this.s,
498
+ pc: this.pc,
499
+ p: this.p.asByte(),
500
+ nmiLevel: this._nmiLevel,
501
+ nmiEdge: this._nmiEdge,
502
+ takeInt: this.takeInt,
503
+ // The parasite has no scheduler of its own, so this is not relative to any epoch.
504
+ cycles: this.cycles,
505
+ romPaged: this.romPaged,
506
+ memory: this.memory.slice(),
507
+ rom: includeRoms ? this.rom.slice() : undefined,
508
+ ula: this.tube.snapshotState(),
509
+ };
510
+ }
511
+
512
+ restoreState(state) {
513
+ this.a = state.a;
514
+ this.x = state.x;
515
+ this.y = state.y;
516
+ this.s = state.s;
517
+ this.pc = state.pc;
518
+ this.p.setFromByte(state.p);
519
+ this._nmiLevel = state.nmiLevel;
520
+ this._nmiEdge = state.nmiEdge;
521
+ this.takeInt = state.takeInt;
522
+ this.cycles = state.cycles;
523
+ this.romPaged = state.romPaged;
524
+ this.memory.set(state.memory);
525
+ if (state.rom) this.rom.set(state.rom);
526
+
527
+ // After the registers, so the edge-triggered NMI moves from its saved level to the one
528
+ // the ULA implies: no edge if they agree, and the edge it is owed if they do not.
529
+ this.tube.restoreState(state.ula);
530
+ }
531
+
485
532
  async loadOs() {
486
533
  console.log("Loading tube rom from roms/" + this.model.os);
487
534
  const tubeRom = this.rom;
@@ -620,10 +667,12 @@ export class Cpu6502 extends Base6502 {
620
667
  this.cpuMultiplier = this.config.cpuMultiplier;
621
668
  this.videoCyclesBatch = this.config.videoCyclesBatch | 0;
622
669
  this.peripheralCyclesPerSecond = 2 * 1000 * 1000;
623
- this.tube = model.tube ? new Tube6502(model.tube, this) : new FakeTube();
624
- if (model.tube && this.config.tubeCpuMultiplier) {
625
- this.tube.cpuMultiplier = this.config.tubeCpuMultiplier;
626
- }
670
+ this.hasTube = !!this.config.tube;
671
+ this.hasMusic5000 = !!this.config.hasMusic5000;
672
+ this.hasTeletextAdaptor = !!this.config.hasTeletextAdaptor;
673
+ this.tube = this.hasTube
674
+ ? new Tube6502(this.config.tube, this, { cpuMultiplier: this.config.tubeCpuMultiplier })
675
+ : new FakeTube();
627
676
  this.music5000PageSel = 0;
628
677
  this.econet = econet;
629
678
 
@@ -799,7 +848,7 @@ export class Cpu6502 extends Base6502 {
799
848
 
800
849
  switch (addr & ~0x0003) {
801
850
  case 0xfc10:
802
- if (this.model.hasTeletextAdaptor) return this.teletextAdaptor.read(addr - 0xfc10);
851
+ if (this.hasTeletextAdaptor) return this.teletextAdaptor.read(addr - 0xfc10);
803
852
  break;
804
853
  case 0xfc20:
805
854
  case 0xfc24:
@@ -822,7 +871,7 @@ export class Cpu6502 extends Base6502 {
822
871
  // IDE
823
872
  break;
824
873
  case 0xfcfc:
825
- if (addr === 0xfcff && this.model.hasMusic5000) return this.music5000PageSel;
874
+ if (addr === 0xfcff && this.hasMusic5000) return this.music5000PageSel;
826
875
  break;
827
876
  case 0xfe00:
828
877
  case 0xfe04:
@@ -909,7 +958,7 @@ export class Cpu6502 extends Base6502 {
909
958
  return this.tube.read(addr);
910
959
  }
911
960
 
912
- if (this.model.hasMusic5000) {
961
+ if (this.hasMusic5000) {
913
962
  if ((this.music5000PageSel & 0xf0) === 0x30 && (addr & 0xff00) === 0xfd00) {
914
963
  return this.music5000.read(this.music5000PageSel, addr);
915
964
  }
@@ -966,14 +1015,14 @@ export class Cpu6502 extends Base6502 {
966
1015
  addr &= 0xffff;
967
1016
  b |= 0;
968
1017
 
969
- if (this.model.hasMusic5000 && (addr & 0xff00) === 0xfd00 && (this.music5000PageSel & 0xf0) === 0x30) {
1018
+ if (this.hasMusic5000 && (addr & 0xff00) === 0xfd00 && (this.music5000PageSel & 0xf0) === 0x30) {
970
1019
  this.music5000.write(this.music5000PageSel, addr, b);
971
1020
  return;
972
1021
  }
973
1022
 
974
1023
  switch (addr & ~0x0003) {
975
1024
  case 0xfc10:
976
- if (this.model.hasTeletextAdaptor) return this.teletextAdaptor.write(addr - 0xfc10, b);
1025
+ if (this.hasTeletextAdaptor) return this.teletextAdaptor.write(addr - 0xfc10, b);
977
1026
  break;
978
1027
  case 0xfc20:
979
1028
  case 0xfc24:
@@ -996,7 +1045,7 @@ export class Cpu6502 extends Base6502 {
996
1045
  // IDE
997
1046
  break;
998
1047
  case 0xfcfc:
999
- if (addr === 0xfcff && this.model.hasMusic5000) {
1048
+ if (addr === 0xfcff && this.hasMusic5000) {
1000
1049
  this.music5000PageSel = b;
1001
1050
  }
1002
1051
  break;
@@ -1183,6 +1232,7 @@ export class Cpu6502 extends Base6502 {
1183
1232
  acia: this.acia.snapshotState(),
1184
1233
  adc: this.adconverter.snapshotState(),
1185
1234
  fdc: this.fdc.snapshotState(),
1235
+ tube: this.hasTube ? this.tube.snapshotState({ includeRoms }) : undefined,
1186
1236
  };
1187
1237
  }
1188
1238
 
@@ -1243,6 +1293,13 @@ export class Cpu6502 extends Base6502 {
1243
1293
  if (state.fdc) {
1244
1294
  this.fdc.restoreState(state.fdc);
1245
1295
  }
1296
+
1297
+ // Tube state (v3+). Rather than leave the parasite running on state the host knows
1298
+ // nothing about, reset it; restoreSnapshot() rejects such a pairing before reaching here.
1299
+ if (this.hasTube) {
1300
+ if (state.tube) this.tube.restoreState(state.tube);
1301
+ else this.tube.reset(true);
1302
+ }
1246
1303
  }
1247
1304
 
1248
1305
  reset(hard) {
@@ -1304,7 +1361,7 @@ export class Cpu6502 extends Base6502 {
1304
1361
  this.adconverter.reset();
1305
1362
 
1306
1363
  this.touchScreen = new TouchScreen(this.scheduler);
1307
- if (this.model.hasTeletextAdaptor) this.teletextAdaptor = new TeletextAdaptor(this);
1364
+ if (this.hasTeletextAdaptor) this.teletextAdaptor = new TeletextAdaptor(this);
1308
1365
  if (this.econet) this.filestore = new Filestore(this, this.econet);
1309
1366
  }
1310
1367
 
@@ -1364,8 +1421,9 @@ export class Cpu6502 extends Base6502 {
1364
1421
  // Builds common code between polltimeSlow and polltimeFast
1365
1422
  buildPolltime() {
1366
1423
  const nop = (_cycles) => {};
1367
- const tubeStuff = (cycles) => (this.model.tube ? this.tube.execute(cycles) : nop);
1368
- const teletextStuff = this.teletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
1424
+ const tubeStuff = this.hasTube ? (cycles) => this.tube.execute(cycles) : nop;
1425
+ // The adaptor itself does not exist until reset() runs, so branch on the fitting, not the object.
1426
+ const teletextStuff = this.hasTeletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
1369
1427
  const musicStuff = this.music5000 ? (cycles) => this.music5000.polltime(cycles) : nop;
1370
1428
  const econetStuff = this.econet
1371
1429
  ? (cycles) => {
@@ -1506,7 +1564,7 @@ export class Cpu6502 extends Base6502 {
1506
1564
  if (this.model.os.length) {
1507
1565
  await this.loadOs.apply(this, this.model.os);
1508
1566
  }
1509
- if (this.model.tube) {
1567
+ if (this.hasTube) {
1510
1568
  await this.tube.loadOs();
1511
1569
  }
1512
1570
  this.reset(true);
package/src/app/app.js CHANGED
@@ -5,6 +5,8 @@ import * as fs from "fs";
5
5
  import * as path from "path";
6
6
  import { ArgumentParser } from "argparse";
7
7
 
8
+ import { getArguments } from "./args.js";
9
+
8
10
  const store = new Store();
9
11
 
10
12
  ipcMain.on("set-title", (event, title) => {
@@ -20,21 +22,6 @@ ipcMain.on("save-settings", (event, settings) => {
20
22
 
21
23
  const isMac = process.platform === "darwin";
22
24
 
23
- function getArguments() {
24
- // Heinous hack to get "built" versions working
25
- let args;
26
- if (path.basename(process.argv[0]) === "jsbeeb")
27
- // Is this ia "built" version?
28
- args = process.argv.slice(1);
29
- else args = process.argv.slice(2);
30
-
31
- // Filter out Chrome switches that appear in process.argv. The snap wrapper
32
- // adds --no-sandbox for compatibility, and `--disable-gpu` might be useful.
33
- // Note that we don't support snap any more, but these seemed useful to leave.
34
- const ignoredChromeFlags = ["--no-sandbox", "--disable-gpu"];
35
- return args.filter((arg) => !ignoredChromeFlags.includes(arg));
36
- }
37
-
38
25
  const parser = new ArgumentParser({
39
26
  prog: "jsbeeb",
40
27
  add_help: true,
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Returns the command-line arguments the user actually passed to jsbeeb, with
5
+ * the Electron binary path(s) and known runtime-injected flags stripped.
6
+ *
7
+ * @param {string[]} [argv=process.argv] Full argv array.
8
+ * @param {boolean} [defaultApp=process.defaultApp]
9
+ * True when running under `electron .` (dev). Electron only sets
10
+ * process.defaultApp in that case, so we use it instead of comparing
11
+ * basename(argv[0]) === "jsbeeb" (the previous check), which failed on
12
+ * Windows where the binary is jsbeeb.exe, silently dropping the first
13
+ * user argument. See issue #684.
14
+ * @returns {string[]}
15
+ */
16
+ export function getArguments(argv = process.argv, defaultApp = process.defaultApp) {
17
+ // In a packaged Electron app argv is [appBinary, ...userArgs]; when running
18
+ // under `electron .` (dev) it is [electronBinary, appPath, ...userArgs].
19
+ const args = defaultApp ? argv.slice(2) : argv.slice(1);
20
+
21
+ // Filter out Chrome switches that appear in argv. The snap wrapper adds
22
+ // --no-sandbox for compatibility, and --disable-gpu might be useful. We
23
+ // don't support snap any more, but these seemed useful to leave.
24
+ const ignoredChromeFlags = ["--no-sandbox", "--disable-gpu"];
25
+ return args.filter((arg) => !ignoredChromeFlags.includes(arg));
26
+ }
@@ -255,7 +255,145 @@ function readString(data, pos) {
255
255
  return str;
256
256
  }
257
257
 
258
- function parseBemV3(buffer) {
258
+ // b-em writes the tube ULA as a raw struct dump. Version 2 is all single-byte fields, so it has
259
+ // no padding and can be read directly; version 1 used native ints and is not supported here.
260
+ const BemTubeUlaVersion = 2;
261
+ const BemTubeUlaOffsets = {
262
+ ph1: 0,
263
+ ph2: 24,
264
+ ph3: 25,
265
+ ph4: 27,
266
+ hp1: 29, // phl (byte 28) is a latch jsbeeb does not model
267
+ hp2: 30,
268
+ hp3: 31,
269
+ hp4: 33,
270
+ hstat: 35, // hpl (byte 34) likewise unmodelled
271
+ pstat: 39,
272
+ r1stat: 43,
273
+ ph1head: 45, // ph1tail (byte 44) matters only to b-em, which appends there
274
+ ph1count: 46,
275
+ ph3pos: 47,
276
+ hp3pos: 48,
277
+ };
278
+ const BemTubeUlaSize = 49;
279
+ const BemPh1Size = 24;
280
+ // 9 bytes of registers, then RAM, then ROM.
281
+ const BemTubeRegisterBytes = 9;
282
+ const BemTubeRamSize = 65536;
283
+ // b-em's other co-processors are different CPUs, and its "6502 Turbo" has 16MB of RAM.
284
+ const BemSupportedTubes = ["6502 Internal", "6502 External"];
285
+
286
+ /**
287
+ * Convert b-em's tube sections into jsbeeb tube state.
288
+ * @param {object} sections - parsed v3 sections
289
+ * @param {string|null} tubeName - co-processor name from the model section
290
+ * @returns {Promise<object|null>} jsbeeb tube state, or null if no usable co-processor
291
+ */
292
+ async function parseBemTube(sections, tubeName) {
293
+ // b-em names the model's co-processor even when it failed to start one, so naming a tube but
294
+ // carrying no state for it is valid. Carrying only one of the two sections is not.
295
+ if (!sections["T"] && !sections["P"]) return null;
296
+ if (!sections["T"] || !sections["P"]) {
297
+ throw new Error("Truncated b-em snapshot: it holds only one of the two co-processor sections");
298
+ }
299
+ // b-em only writes these sections for a co-processor it started, which it always names.
300
+ if (!tubeName) {
301
+ throw new Error("Corrupt b-em snapshot: it holds co-processor state but names no co-processor");
302
+ }
303
+ if (!BemSupportedTubes.includes(tubeName)) {
304
+ throw new Error(
305
+ `Unsupported b-em co-processor "${tubeName}": jsbeeb only emulates the 64K 65C02 second processor`,
306
+ );
307
+ }
308
+
309
+ const ulaSection = sections["T"].data;
310
+ if (ulaSection[0] !== BemTubeUlaVersion) {
311
+ throw new Error(`Unsupported b-em tube ULA state version ${ulaSection[0]}`);
312
+ }
313
+ if (ulaSection.length < 2 + BemTubeUlaSize) {
314
+ throw new Error(
315
+ `Truncated b-em tube ULA state: expected ${2 + BemTubeUlaSize} bytes, got ${ulaSection.length}`,
316
+ );
317
+ }
318
+ const romPaged = !!ulaSection[1];
319
+ const ula = ulaSection.slice(2, 2 + BemTubeUlaSize);
320
+ const at = (field) => ula[BemTubeUlaOffsets[field]];
321
+
322
+ // b-em keeps the R1 FIFO as a circular buffer; jsbeeb shifts its contents down on read, so
323
+ // the bytes have to be linearised into the order jsbeeb expects to read them.
324
+ const ph1Count = at("ph1count");
325
+ const ph1Head = at("ph1head");
326
+ // Out-of-range counts would restore a FIFO jsbeeb's own reads could never have produced.
327
+ if (ph1Count > BemPh1Size || ph1Head >= BemPh1Size || at("ph3pos") > 2 || at("hp3pos") > 2) {
328
+ throw new Error("Corrupt b-em tube ULA state: FIFO counts out of range");
329
+ }
330
+ const ph1 = new Uint8Array(BemPh1Size);
331
+ for (let i = 0; i < ph1Count; i++) {
332
+ ph1[i] = ula[BemTubeUlaOffsets.ph1 + ((ph1Head + i) % BemPh1Size)];
333
+ }
334
+
335
+ // Those names come from a config file, so size is the real check. It cannot be exact: the
336
+ // trailing ROM's size comes from that same config, and b-em records it as zero if unset.
337
+ const parasite = await decompress(sections["P"].data, "deflate");
338
+ const romBytes = parasite.length - BemTubeRegisterBytes - BemTubeRamSize;
339
+ if (romBytes < 0 || romBytes > BemTubeRamSize) {
340
+ throw new Error(
341
+ `Unsupported b-em co-processor: expected ${BemTubeRamSize} bytes of parasite RAM, ` +
342
+ `but the state section holds ${parasite.length - BemTubeRegisterBytes}`,
343
+ );
344
+ }
345
+
346
+ return {
347
+ a: parasite[2],
348
+ x: parasite[3],
349
+ y: parasite[4],
350
+ p: parasite[5] | 0x30,
351
+ s: parasite[6],
352
+ pc: parasite[7] | (parasite[8] << 8),
353
+ // b-em's oldnmi is the NMI level as of the parasite's last instruction, so it may lag the
354
+ // line its ULA state implies. b-em's skipint has no jsbeeb equivalent and is dropped.
355
+ nmiLevel: !!parasite[1],
356
+ nmiEdge: false,
357
+ takeInt: false,
358
+ cycles: 0,
359
+ romPaged,
360
+ memory: parasite.slice(BemTubeRegisterBytes, BemTubeRegisterBytes + BemTubeRamSize),
361
+ // The parasite ROM is loaded from file at boot, so b-em's copy is not needed.
362
+ ula: {
363
+ internalStatusRegister: at("r1stat"),
364
+ hostStatus: ula.slice(BemTubeUlaOffsets.hstat, BemTubeUlaOffsets.hstat + 4),
365
+ parasiteStatus: ula.slice(BemTubeUlaOffsets.pstat, BemTubeUlaOffsets.pstat + 4),
366
+ parasiteToHostData: [
367
+ ph1,
368
+ Uint8Array.of(at("ph2")),
369
+ ula.slice(BemTubeUlaOffsets.ph3, BemTubeUlaOffsets.ph3 + 2),
370
+ Uint8Array.of(at("ph4")),
371
+ ],
372
+ hostToParasiteData: [
373
+ Uint8Array.of(at("hp1")),
374
+ Uint8Array.of(at("hp2")),
375
+ ula.slice(BemTubeUlaOffsets.hp3, BemTubeUlaOffsets.hp3 + 2),
376
+ Uint8Array.of(at("hp4")),
377
+ ],
378
+ parasiteToHostFifoByteCount1: ph1Count,
379
+ parasiteToHostFifoByteCount3: at("ph3pos"),
380
+ hostToParasiteFifoByteCount3: at("hp3pos"),
381
+ },
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Read the co-processor name from b-em's model section, if one was fitted.
387
+ * @returns {string|null}
388
+ */
389
+ function readBemTubeName(data, pos) {
390
+ for (let i = 0; i < 4; i++) readString(data, pos); // os, cmos, rom setup, fdc names
391
+ pos.offset += 6; // model flag bytes
392
+ const fitted = data[pos.offset++];
393
+ return fitted ? readString(data, pos) : null;
394
+ }
395
+
396
+ async function parseBemV3(buffer) {
259
397
  const bytes = new Uint8Array(buffer);
260
398
  let offset = 8; // Skip "BEMSNAP3" signature
261
399
 
@@ -281,11 +419,13 @@ function parseBemV3(buffer) {
281
419
  // Parse model section to determine jsbeeb model name.
282
420
  // Use jsbeeb synonyms (from models.js) so findModel() resolves them.
283
421
  let modelName = "B";
422
+ let tubeName = null;
284
423
  if (sections["m"]) {
285
424
  const pos = { offset: 0 };
286
425
  const data = sections["m"].data;
287
426
  readVar(data, pos); // curmodel index (skip)
288
427
  const name = readString(data, pos);
428
+ tubeName = readBemTubeName(data, pos);
289
429
  if (name.includes("Master")) {
290
430
  if (name.includes("ADFS")) modelName = "MasterADFS";
291
431
  else if (name.includes("ANFS")) modelName = "MasterANFS";
@@ -311,23 +451,21 @@ function parseBemV3(buffer) {
311
451
  if (memSection) {
312
452
  // Memory is zlib-compressed; decompression is async.
313
453
  // Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
314
- return decompress(memSection.data, "deflate").then((memData) => {
315
- cpuState.fe30 = memData[0];
316
- cpuState.fe34 = memData[1];
317
- const ramStart = 2;
318
- const ramSize = 64 * 1024;
319
- ram.set(memData.slice(ramStart, ramStart + ramSize));
320
- const romStart = ramStart + ramSize;
321
- if (memData.length > romStart) {
322
- roms = memData.slice(romStart, romStart + 262144);
323
- }
324
- return finishV3Parse(modelName, cpuState, ram, roms, sections);
325
- });
454
+ const memData = await decompress(memSection.data, "deflate");
455
+ cpuState.fe30 = memData[0];
456
+ cpuState.fe34 = memData[1];
457
+ const ramStart = 2;
458
+ const ramSize = 64 * 1024;
459
+ ram.set(memData.slice(ramStart, ramStart + ramSize));
460
+ const romStart = ramStart + ramSize;
461
+ if (memData.length > romStart) {
462
+ roms = memData.slice(romStart, romStart + 262144);
463
+ }
326
464
  }
327
- return finishV3Parse(modelName, cpuState, ram, roms, sections);
465
+ return finishV3Parse(modelName, cpuState, ram, roms, sections, await parseBemTube(sections, tubeName));
328
466
  }
329
467
 
330
- function finishV3Parse(modelName, cpuState, ram, roms, sections) {
468
+ function finishV3Parse(modelName, cpuState, ram, roms, sections, tube) {
331
469
  // Parse system VIA
332
470
  let sysvia = convertViaState(
333
471
  {
@@ -466,5 +604,6 @@ function finishV3Parse(modelName, cpuState, ram, roms, sections) {
466
604
  uservia,
467
605
  buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, crtcCounters),
468
606
  soundChip,
607
+ tube,
469
608
  );
470
609
  }