jsbeeb 1.11.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 CHANGED
@@ -177,7 +177,7 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
177
177
  Doesn't support the sth: pseudo URL unlike `disc` and `tape`, but if given a ZIP file will attempt to use the `.rom`
178
178
  file assumed to be within.
179
179
  - (mostly internal use) `logFdcCommands`, `logFdcStateChanges` - turn on logging in the disc controller.
180
- - `audioDebug=true` turns on some audio debug graphs.
180
+ - `audioDebug` - show audio queue stats chart.
181
181
 
182
182
  ## Patches
183
183
 
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.11.0",
10
+ "version": "1.12.0",
11
11
  "//": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=22.12.0"
@@ -30,7 +30,6 @@
30
30
  "argparse": "^2.0.1",
31
31
  "bootstrap": "^5.3.8",
32
32
  "bootswatch": "^5.3.8",
33
- "event-emitter-es6": "^1.1.5",
34
33
  "sharp": "^0.34.5",
35
34
  "smoothie": "^1.36.1"
36
35
  },
package/src/6502.js CHANGED
@@ -1208,6 +1208,13 @@ export class Cpu6502 extends Base6502 {
1208
1208
  if (state.roms) {
1209
1209
  this.ramRomOs.set(state.roms.slice(0, 16 * 16384), this.romOffset);
1210
1210
  }
1211
+ // Selectively overwrite individual sideways RAM banks (e.g. from BeebEm UEF import)
1212
+ // without touching ROM banks that jsbeeb has already loaded.
1213
+ if (state.swRamBanks) {
1214
+ for (const [bank, data] of Object.entries(state.swRamBanks)) {
1215
+ this.ramRomOs.set(data.slice(0, 16384), this.romOffset + Number(bank) * 16384);
1216
+ }
1217
+ }
1211
1218
  this.videoDisplayPage = state.videoDisplayPage;
1212
1219
  this.music5000PageSel = state.music5000PageSel;
1213
1220
 
package/src/app/app.js CHANGED
@@ -242,7 +242,7 @@ const template = [
242
242
  const result = await dialog.showOpenDialog(browserWindow, {
243
243
  title: "Load emulator state",
244
244
  filters: [
245
- { name: "Snapshot files", extensions: ["gz", "json", "snp"] },
245
+ { name: "Snapshot files", extensions: ["gz", "json", "snp", "uef"] },
246
246
  { name: "All files", extensions: ["*"] },
247
247
  ],
248
248
  properties: ["openFile"],
@@ -55,11 +55,11 @@ function init(args) {
55
55
 
56
56
  // Save settings when they change
57
57
  if (config) {
58
- config.on("settings-changed", (settings) => {
59
- api.saveSettings(settings);
58
+ config.addEventListener("settings-changed", (e) => {
59
+ api.saveSettings(e.detail);
60
60
  });
61
- config.on("media-changed", (media) => {
62
- api.saveSettings(media);
61
+ config.addEventListener("media-changed", (e) => {
62
+ api.saveSettings(e.detail);
63
63
  });
64
64
  }
65
65
  }
@@ -5,6 +5,8 @@ import { decompress } from "./utils.js";
5
5
  // v1 (BEMSNAP1): Fixed-size 327,885 byte packed struct. Reference: beebjit state.c
6
6
  // v3 (BEMSNAP3): Section-based with key+size headers, zlib-compressed memory. Reference: b-em savestate.c
7
7
 
8
+ import { volumeTable, buildVideoState, buildSnapshot } from "./snapshot-helpers.js";
9
+
8
10
  const BemV1Size = 327885;
9
11
 
10
12
  // v1 struct offsets
@@ -114,7 +116,7 @@ function convertViaState(bemVia, ic32) {
114
116
  t2l: bemVia.t2l,
115
117
  t1c: bemVia.t1c,
116
118
  t2c: bemVia.t2c,
117
- t1hit: !!bemVia.t1hit,
119
+ t1hit: bemVia.acr & 0x40 ? false : !!bemVia.t1hit,
118
120
  t2hit: !!bemVia.t2hit,
119
121
  portapins: 0xff,
120
122
  portbpins: 0xff,
@@ -135,17 +137,6 @@ function convertViaState(bemVia, ic32) {
135
137
  return result;
136
138
  }
137
139
 
138
- // Volume lookup table matching soundchip.js
139
- const volumeTable = new Float32Array(16);
140
- (() => {
141
- let f = 1.0;
142
- for (let i = 0; i < 15; ++i) {
143
- volumeTable[i] = f / 4;
144
- f *= Math.pow(10, -0.1);
145
- }
146
- volumeTable[15] = 0;
147
- })();
148
-
149
140
  function convertSoundState(snLatch, snCount, snStat, snVol, snNoise, snShift) {
150
141
  const registers = new Uint16Array(4);
151
142
  const counter = new Float32Array(4);
@@ -181,178 +172,6 @@ function convertSoundState(snLatch, snCount, snStat, snVol, snNoise, snShift) {
181
172
  };
182
173
  }
183
174
 
184
- function buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, crtcCounters) {
185
- const regs = new Uint8Array(32);
186
- regs.set(crtcRegs.slice(0, 18));
187
- const actualPal = new Uint8Array(16);
188
- for (let i = 0; i < 16; i++) actualPal[i] = ulaPalette[i] & 0x0f;
189
-
190
- // Use NULA collook if provided, otherwise use default BBC palette
191
- const collook =
192
- nulaCollook ||
193
- new Int32Array([
194
- 0xff000000, 0xff0000ff, 0xff00ff00, 0xff00ffff, 0xffff0000, 0xffff00ff, 0xffffff00, 0xffffffff, 0xff000000,
195
- 0xff0000ff, 0xff00ff00, 0xff00ffff, 0xffff0000, 0xffff00ff, 0xffffff00, 0xffffffff,
196
- ]);
197
-
198
- // Compute ulaPal from actualPal + collook, matching jsbeeb's Ula._recomputeUlaPal
199
- const flashEnabled = !!(ulaControl & 1);
200
- const defaultFlash = new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1]);
201
- const flash = defaultFlash;
202
- const ulaPal = new Int32Array(16);
203
- for (let i = 0; i < 16; i++) {
204
- const palVal = actualPal[i];
205
- let colour = collook[(palVal & 0xf) ^ 7];
206
- if (palVal & 8 && flashEnabled && flash[(palVal & 7) ^ 7]) {
207
- colour = collook[palVal & 0xf];
208
- }
209
- ulaPal[i] = colour;
210
- }
211
-
212
- return {
213
- regs,
214
- bitmapX: 0,
215
- bitmapY: 0,
216
- oddClock: false,
217
- frameCount: 0,
218
- doEvenFrameLogic: false,
219
- isEvenRender: true,
220
- lastRenderWasEven: false,
221
- firstScanline: true,
222
- inHSync: false,
223
- inVSync: false,
224
- hadVSyncThisRow: false,
225
- checkVertAdjust: false,
226
- endOfMainLatched: false,
227
- endOfVertAdjustLatched: false,
228
- endOfFrameLatched: false,
229
- inVertAdjust: false,
230
- inDummyRaster: false,
231
- hpulseWidth: regs[3] & 0x0f,
232
- vpulseWidth: (regs[3] & 0xf0) >>> 4,
233
- hpulseCounter: 0,
234
- vpulseCounter: 0,
235
- dispEnabled: 0x3f,
236
- horizCounter: crtcCounters ? crtcCounters.hc : 0,
237
- vertCounter: crtcCounters ? crtcCounters.vc : 0,
238
- scanlineCounter: crtcCounters ? crtcCounters.sc : 0,
239
- vertAdjustCounter: 0,
240
- addr: crtcCounters ? crtcCounters.ma : (regs[13] | (regs[12] << 8)) & 0x3fff,
241
- lineStartAddr: crtcCounters ? crtcCounters.maback : (regs[13] | (regs[12] << 8)) & 0x3fff,
242
- nextLineStartAddr: crtcCounters ? crtcCounters.maback : (regs[13] | (regs[12] << 8)) & 0x3fff,
243
- ulactrl: ulaControl,
244
- pixelsPerChar: ulaControl & 0x10 ? 8 : 16,
245
- halfClock: !(ulaControl & 0x10),
246
- ulaMode: (ulaControl >>> 2) & 3,
247
- teletextMode: !!(ulaControl & 2),
248
- displayEnableSkew: Math.min((regs[8] & 0x30) >>> 4, 2),
249
- ulaPal,
250
- actualPal,
251
- cursorOn: false,
252
- cursorOff: false,
253
- cursorOnThisFrame: false,
254
- cursorDrawIndex: 0,
255
- cursorPos: (regs[15] | (regs[14] << 8)) & 0x3fff,
256
- interlacedSyncAndVideo: (regs[8] & 3) === 3,
257
- screenSubtract: 0,
258
- ula: {
259
- collook: collook.slice(),
260
- flash: new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1]),
261
- paletteWriteFlag: false,
262
- paletteFirstByte: 0,
263
- paletteMode: 0,
264
- horizontalOffset: 0,
265
- leftBlank: 0,
266
- disabled: false,
267
- attributeMode: 0,
268
- attributeText: 0,
269
- },
270
- crtc: { curReg: 0 },
271
- teletext: {
272
- prevCol: 0,
273
- col: 7,
274
- bg: 0,
275
- sep: false,
276
- dbl: false,
277
- oldDbl: false,
278
- secondHalfOfDouble: false,
279
- wasDbl: false,
280
- gfx: false,
281
- flash: false,
282
- flashOn: false,
283
- flashTime: 0,
284
- heldChar: 0,
285
- holdChar: false,
286
- dataQueue: [0, 0, 0, 0],
287
- scanlineCounter: 0,
288
- levelDEW: false,
289
- levelDISPTMG: false,
290
- levelRA0: false,
291
- nextGlyphs: "normal",
292
- curGlyphs: "normal",
293
- heldGlyphs: "normal",
294
- },
295
- };
296
- }
297
-
298
- const DefaultAcia = {
299
- sr: 0x02,
300
- cr: 0x00,
301
- dr: 0x00,
302
- rs423Selected: false,
303
- motorOn: false,
304
- tapeCarrierCount: 0,
305
- tapeDcdLineLevel: false,
306
- hadDcdHigh: false,
307
- serialReceiveRate: 19200,
308
- serialReceiveCyclesPerByte: 0,
309
- txCompleteTaskOffset: null,
310
- runTapeTaskOffset: null,
311
- runRs423TaskOffset: null,
312
- };
313
-
314
- const DefaultAdc = { status: 0x40, low: 0x00, high: 0x00, taskOffset: null };
315
-
316
- function buildSnapshot(modelName, cpuState, ram, roms, sysvia, uservia, video, soundChip) {
317
- return {
318
- format: "jsbeeb-snapshot",
319
- version: 1,
320
- model: modelName,
321
- timestamp: new Date().toISOString(),
322
- importedFrom: "b-em",
323
- state: {
324
- a: cpuState.a,
325
- x: cpuState.x,
326
- y: cpuState.y,
327
- s: cpuState.s,
328
- pc: cpuState.pc,
329
- p: cpuState.flags | 0x30,
330
- nmiLevel: !!cpuState.nmi,
331
- nmiEdge: false,
332
- halted: false,
333
- takeInt: false,
334
- romsel: cpuState.fe30 ?? 0,
335
- acccon: cpuState.fe34 ?? 0,
336
- videoDisplayPage: 0,
337
- currentCycles: 0,
338
- targetCycles: 0,
339
- cycleSeconds: 0,
340
- peripheralCycles: 0,
341
- videoCycles: 0,
342
- music5000PageSel: 0,
343
- ram,
344
- roms,
345
- scheduler: { epoch: 0 },
346
- sysvia,
347
- uservia,
348
- video,
349
- soundChip,
350
- acia: { ...DefaultAcia },
351
- adc: { ...DefaultAdc },
352
- },
353
- };
354
- }
355
-
356
175
  // ── V1 parser (BEMSNAP1, fixed struct) ──────────────────────────────
357
176
 
358
177
  function parseBemV1(buffer) {
@@ -402,6 +221,7 @@ function parseBemV1(buffer) {
402
221
  const snShift = view.getUint16(soundOff + 53, true);
403
222
 
404
223
  return buildSnapshot(
224
+ "b-em",
405
225
  "B",
406
226
  cpuState,
407
227
  ram,
@@ -637,6 +457,7 @@ function finishV3Parse(modelName, cpuState, ram, roms, sections) {
637
457
  }
638
458
 
639
459
  return buildSnapshot(
460
+ "b-em",
640
461
  modelName,
641
462
  cpuState,
642
463
  ram,
package/src/config.js CHANGED
@@ -1,9 +1,8 @@
1
1
  "use strict";
2
- import EventEmitter from "event-emitter-es6";
3
- import { findModel } from "./models.js";
2
+ import { allModels, findModel } from "./models.js";
4
3
  import { getFilterForMode } from "./canvas.js";
5
4
 
6
- export class Config extends EventEmitter {
5
+ export class Config extends EventTarget {
7
6
  constructor(onChange, onClose) {
8
7
  super();
9
8
  this.onChange = onChange;
@@ -25,16 +24,28 @@ export class Config extends EventEmitter {
25
24
  configuration.addEventListener("hide.bs.modal", () => {
26
25
  this.onClose(this.changed);
27
26
  if (Object.keys(this.changed).length > 0) {
28
- this.emit("settings-changed", this.changed);
27
+ this.dispatchEvent(new CustomEvent("settings-changed", { detail: this.changed }));
29
28
  }
30
29
  });
31
30
 
32
- for (const link of document.querySelectorAll(".model-menu a")) {
33
- link.addEventListener("click", (e) => {
34
- this.changed.model = e.target.dataset.target;
35
- this.setDropdownText(e.target.textContent);
36
- });
31
+ const modelMenu = document.querySelector(".model-menu");
32
+ for (const model of allModels) {
33
+ if (model.synonyms.length === 0) continue; // skip non-selectable models (e.g. Tube65C02)
34
+ const li = document.createElement("li");
35
+ const a = document.createElement("a");
36
+ a.href = "#";
37
+ a.className = "dropdown-item";
38
+ a.dataset.target = model.synonyms[0];
39
+ a.textContent = model.name;
40
+ li.appendChild(a);
41
+ modelMenu.appendChild(li);
37
42
  }
43
+ modelMenu.addEventListener("click", (e) => {
44
+ const link = e.target.closest("a[data-target]");
45
+ if (!link) return;
46
+ this.changed.model = link.dataset.target;
47
+ this.setDropdownText(link.textContent);
48
+ });
38
49
 
39
50
  document.getElementById("65c02").addEventListener("click", () => {
40
51
  this.changed.coProcessor = document.getElementById("65c02").checked;
package/src/keyboard.js CHANGED
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
  import * as utils from "./utils.js";
3
- import EventEmitter from "event-emitter-es6";
4
3
 
5
4
  const isMac = typeof window !== "undefined" && /^Mac/i.test(window.navigator?.platform || "");
6
5
 
@@ -15,7 +14,7 @@ const isMac = typeof window !== "undefined" && /^Mac/i.test(window.navigator?.pl
15
14
  /**
16
15
  * Keyboard class that handles all keyboard related functionality
17
16
  */
18
- export class Keyboard extends EventEmitter {
17
+ export class Keyboard extends EventTarget {
19
18
  /**
20
19
  * Create a new Keyboard instance with specified configuration
21
20
  * @param {KeyboardConfig} config - The configuration object
@@ -182,7 +181,7 @@ export class Keyboard extends EventEmitter {
182
181
  // Handle debugger 'g' key press
183
182
  if (this.dbgr.enabled() && code === LOWERCASE_G) {
184
183
  this.dbgr.hide();
185
- this.emit("resume");
184
+ this.dispatchEvent(new Event("resume"));
186
185
  return;
187
186
  }
188
187
 
@@ -193,7 +192,7 @@ export class Keyboard extends EventEmitter {
193
192
  return;
194
193
  } else if (code === LOWERCASE_N) {
195
194
  this.requestStep();
196
- this.emit("resume");
195
+ this.dispatchEvent(new Event("resume"));
197
196
  return;
198
197
  }
199
198
  }
@@ -247,7 +246,7 @@ export class Keyboard extends EventEmitter {
247
246
  */
248
247
  _handleSpecialKeys(code) {
249
248
  if (code === utils.keyCodes.F12 || code === utils.keyCodes.BREAK) {
250
- this.emit("break", true);
249
+ this.dispatchEvent(new CustomEvent("break", { detail: true }));
251
250
  this.processor.setReset(true);
252
251
  return true;
253
252
  } else if (isMac && code === utils.keyCodes.CAPSLOCK) {
@@ -278,7 +277,7 @@ export class Keyboard extends EventEmitter {
278
277
 
279
278
  // Handle special key cases
280
279
  if (code === utils.keyCodes.F12 || code === utils.keyCodes.BREAK) {
281
- this.emit("break", false);
280
+ this.dispatchEvent(new CustomEvent("break", { detail: false }));
282
281
  this.processor.setReset(false);
283
282
  return;
284
283
  } else if (isMac && code === utils.keyCodes.CAPSLOCK) {
@@ -308,14 +307,18 @@ export class Keyboard extends EventEmitter {
308
307
  setTimeout(() => this.processor.sysvia.keyUp(utils.keyCodes.CAPSLOCK), CAPS_LOCK_DELAY);
309
308
 
310
309
  if (isMac && window.localStorage && !window.localStorage.getItem("warnedAboutRubbishMacs")) {
311
- this.emit("showError", {
312
- context: "handling caps lock on Mac OS X",
313
- error: `Mac OS X does not generate key up events for caps lock presses.
314
- jsbeeb can only simulate a 'tap' of the caps lock key. This means it doesn't work well for games
315
- that use caps lock for left or fire, as we can't tell if it's being held down. If you need to play
310
+ this.dispatchEvent(
311
+ new CustomEvent("showError", {
312
+ detail: {
313
+ context: "handling caps lock on Mac OS X",
314
+ error: `Mac OS X does not generate key up events for caps lock presses.
315
+ jsbeeb can only simulate a 'tap' of the caps lock key. This means it doesn't work well for games
316
+ that use caps lock for left or fire, as we can't tell if it's being held down. If you need to play
316
317
  such a game, please see the documentation about remapping keys.
317
318
  Close this window to continue (you won't see this error again)`,
318
- });
319
+ },
320
+ }),
321
+ );
319
322
  window.localStorage.setItem("warnedAboutRubbishMacs", "true");
320
323
  }
321
324
  }
@@ -437,7 +440,7 @@ export class Keyboard extends EventEmitter {
437
440
  */
438
441
  pauseEmulation() {
439
442
  this.pauseEmu = true;
440
- this.emit("pause");
443
+ this.dispatchEvent(new Event("pause"));
441
444
  }
442
445
 
443
446
  /**
@@ -445,6 +448,6 @@ export class Keyboard extends EventEmitter {
445
448
  */
446
449
  resumeEmulation() {
447
450
  this.pauseEmu = false;
448
- this.emit("resume");
451
+ this.dispatchEvent(new Event("resume"));
449
452
  }
450
453
  }
package/src/main.js CHANGED
@@ -28,8 +28,9 @@ import { MicrophoneInput } from "./microphone-input.js";
28
28
  import { SpeechOutput } from "./speech-output.js";
29
29
  import { MouseJoystickSource } from "./mouse-joystick-source.js";
30
30
  import { getFilterForMode } from "./canvas.js";
31
- import { createSnapshot, restoreSnapshot, snapshotToJSON, snapshotFromJSON, modelsCompatible } from "./snapshot.js";
31
+ import { createSnapshot, restoreSnapshot, snapshotToJSON, snapshotFromJSON, isSameModel } from "./snapshot.js";
32
32
  import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
33
+ import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
33
34
  import { RewindBuffer } from "./rewind.js";
34
35
  import { RewindUI } from "./rewind-ui.js";
35
36
  import {
@@ -102,6 +103,7 @@ const paramTypes = {
102
103
  coProcessor: ParamTypes.BOOL,
103
104
  mouseJoystickEnabled: ParamTypes.BOOL,
104
105
  speechOutput: ParamTypes.BOOL,
106
+ audioDebug: ParamTypes.BOOL,
105
107
 
106
108
  // Numeric parameters
107
109
  speed: ParamTypes.INT,
@@ -259,7 +261,7 @@ const config = new Config(
259
261
  updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
260
262
 
261
263
  if (changed.microphoneChannel !== undefined) {
262
- setupMicrophone().then(() => {});
264
+ setupMicrophone();
263
265
  }
264
266
  }
265
267
  if (changed.speechOutput !== undefined) {
@@ -383,7 +385,9 @@ video = new Video(model.isMaster, canvas.fb32, function paint(minx, miny, maxx,
383
385
  });
384
386
  if (parsedQuery.fakeVideo !== undefined) video = new FakeVideo();
385
387
 
386
- const audioStatsNode = document.getElementById("audio-stats");
388
+ const audioStatsEl = document.getElementById("audio-stats");
389
+ if (audioStatsEl) audioStatsEl.hidden = !parsedQuery.audioDebug;
390
+ const audioStatsNode = parsedQuery.audioDebug ? audioStatsEl : null;
387
391
  const audioHandler = new AudioHandler({
388
392
  warningNode: document.getElementById("audio-warning"),
389
393
  statsNode: audioStatsNode,
@@ -391,7 +395,6 @@ const audioHandler = new AudioHandler({
391
395
  audioFilterQ,
392
396
  noSeek,
393
397
  });
394
- if (!parsedQuery.audioDebug) audioStatsNode.style.display = "none";
395
398
  // Firefox will report that audio is suspended even when it will
396
399
  // start playing without user interaction, so we need to delay a
397
400
  // little to get a reliable indication.
@@ -492,8 +495,12 @@ pastetext.addEventListener("dragover", function (event) {
492
495
  pastetext.addEventListener("drop", async function (event) {
493
496
  utils.noteEvent("local", "drop");
494
497
  const file = event.dataTransfer.files[0];
495
- if (isSnapshotFile(file.name)) {
496
- await loadStateFromFile(file);
498
+ const arrayBuffer = await file.arrayBuffer();
499
+ if (isSnapshotFile(file.name, arrayBuffer)) {
500
+ await loadStateFromFile(file, arrayBuffer);
501
+ } else if (file.name.toLowerCase().endsWith(".uef")) {
502
+ // Regular UEF tape image (not a BeebEm save state)
503
+ processor.acia.setTape(loadTapeFromData(file.name, new Uint8Array(arrayBuffer)));
497
504
  } else {
498
505
  await loadHTMLFile(file);
499
506
  }
@@ -501,7 +508,7 @@ pastetext.addEventListener("drop", async function (event) {
501
508
 
502
509
  const cubMonitor = document.getElementById("cub-monitor");
503
510
  function onCubMouseEvent(evt) {
504
- audioHandler.tryResume().then(() => {});
511
+ audioHandler.tryResume();
505
512
  if (document.activeElement !== document.body) document.activeElement.blur();
506
513
  const cubRect = cubMonitor.getBoundingClientRect();
507
514
  const screenRect = screenCanvas.getBoundingClientRect();
@@ -737,12 +744,12 @@ keyboard = new Keyboard({
737
744
  keyLayout,
738
745
  dbgr,
739
746
  });
740
- keyboard.on("showError", ({ context, error }) => showError(context, error));
741
- keyboard.on("pause", () => stop(false));
742
- keyboard.on("resume", () => go());
743
- keyboard.on("break", (pressed) => {
747
+ keyboard.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
748
+ keyboard.addEventListener("pause", () => stop(false));
749
+ keyboard.addEventListener("resume", () => go());
750
+ keyboard.addEventListener("break", (e) => {
744
751
  // F12/Break: Reset processor
745
- if (pressed) utils.noteEvent("keyboard", "press", "break");
752
+ if (e.detail) utils.noteEvent("keyboard", "press", "break");
746
753
  });
747
754
 
748
755
  // Register default key handlers
@@ -846,8 +853,8 @@ keyboard.registerKeyHandler(
846
853
 
847
854
  // Setup key handlers
848
855
  document.addEventListener("keydown", (evt) => {
849
- audioHandler.tryResume().then(() => {});
850
- ensureMicrophoneRunning().then(() => {});
856
+ audioHandler.tryResume();
857
+ ensureMicrophoneRunning();
851
858
  keyboard.keyDown(evt);
852
859
  });
853
860
  document.addEventListener("keypress", (evt) => keyboard.keyPress(evt));
@@ -857,19 +864,19 @@ function setDisc1Image(name) {
857
864
  delete parsedQuery.disc;
858
865
  parsedQuery.disc1 = name;
859
866
  updateUrl();
860
- config.emit("media-changed", { disc1: name });
867
+ config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
861
868
  }
862
869
 
863
870
  function setDisc2Image(name) {
864
871
  parsedQuery.disc2 = name;
865
872
  updateUrl();
866
- config.emit("media-changed", { disc2: name });
873
+ config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
867
874
  }
868
875
 
869
876
  function setTapeImage(name) {
870
877
  parsedQuery.tape = name;
871
878
  updateUrl();
872
- config.emit("media-changed", { tape: name });
879
+ config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
873
880
  }
874
881
 
875
882
  function sthClearList() {
@@ -1354,13 +1361,12 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
1354
1361
  row.classList.remove("template");
1355
1362
  dbList.appendChild(row);
1356
1363
  row.querySelector(".name").textContent = item.name;
1357
- row.addEventListener("click", function () {
1364
+ row.addEventListener("click", async function () {
1358
1365
  utils.noteEvent("google-drive", "click", item.name);
1359
1366
  setDisc1Image(`gd:${item.id}/${item.name}`);
1360
- gdLoad(item).then(function (ssd) {
1361
- processor.fdc.loadDisc(0, ssd);
1362
- });
1363
1367
  googleDriveModal.hide();
1368
+ const ssd = await gdLoad(item);
1369
+ if (ssd) processor.fdc.loadDisc(0, ssd);
1364
1370
  });
1365
1371
  }
1366
1372
  });
@@ -1372,13 +1378,11 @@ for (const image of availableImages) {
1372
1378
  discList.appendChild(elem);
1373
1379
  elem.querySelector(".name").textContent = image.name;
1374
1380
  elem.querySelector(".description").textContent = image.desc;
1375
- elem.addEventListener("click", function () {
1381
+ elem.addEventListener("click", async function () {
1376
1382
  utils.noteEvent("images", "click", image.file);
1377
1383
  setDisc1Image(image.file);
1378
- loadDiscImage(parsedQuery.disc1).then(function (disc) {
1379
- processor.fdc.loadDisc(0, disc);
1380
- });
1381
1384
  $discsModal.hide();
1385
+ processor.fdc.loadDisc(0, await loadDiscImage(parsedQuery.disc1));
1382
1386
  });
1383
1387
  }
1384
1388
 
@@ -1501,14 +1505,16 @@ document.getElementById("save-state").addEventListener("click", async function (
1501
1505
  if (wasRunning) go();
1502
1506
  });
1503
1507
 
1504
- async function loadStateFromFile(file) {
1508
+ async function loadStateFromFile(file, preReadBuffer) {
1505
1509
  const wasRunning = running;
1506
1510
  if (running) stop(false);
1507
1511
  try {
1508
- const arrayBuffer = await file.arrayBuffer();
1512
+ const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
1509
1513
  let snapshot;
1510
1514
  if (isBemSnapshot(arrayBuffer)) {
1511
1515
  snapshot = await parseBemSnapshot(arrayBuffer);
1516
+ } else if (isUefSnapshot(arrayBuffer)) {
1517
+ snapshot = parseUefSnapshot(arrayBuffer);
1512
1518
  } else {
1513
1519
  // Detect gzip (magic bytes 0x1f 0x8b) or plain JSON
1514
1520
  const bytes = new Uint8Array(arrayBuffer);
@@ -1521,7 +1527,7 @@ async function loadStateFromFile(file) {
1521
1527
  }
1522
1528
  snapshot = snapshotFromJSON(text);
1523
1529
  }
1524
- if (!modelsCompatible(snapshot.model, model.name)) {
1530
+ if (!isSameModel(snapshot.model, model.name)) {
1525
1531
  // Model mismatch: stash state and reload with correct model
1526
1532
  sessionStorage.setItem("jsbeeb-pending-state", snapshotToJSON(snapshot));
1527
1533
  const newQuery = { ...parsedQuery, model: snapshot.model };
@@ -1541,9 +1547,13 @@ async function loadStateFromFile(file) {
1541
1547
  if (wasRunning) go();
1542
1548
  }
1543
1549
 
1544
- function isSnapshotFile(filename) {
1550
+ function isSnapshotFile(filename, arrayBuffer) {
1545
1551
  const lower = filename.toLowerCase();
1546
- return lower.endsWith(".snp") || lower.endsWith(".json") || lower.endsWith(".json.gz") || lower.endsWith(".gz");
1552
+ if (lower.endsWith(".snp") || lower.endsWith(".json") || lower.endsWith(".json.gz") || lower.endsWith(".gz"))
1553
+ return true;
1554
+ // .uef can be either a BeebEm save state or a regular tape image - check content
1555
+ if (lower.endsWith(".uef") && arrayBuffer) return isUefSnapshot(arrayBuffer);
1556
+ return false;
1547
1557
  }
1548
1558
 
1549
1559
  document.getElementById("load-state").addEventListener("change", async function (event) {
@@ -1684,8 +1694,10 @@ const startPromise = (async () => {
1684
1694
  return Promise.all(imageLoads);
1685
1695
  })();
1686
1696
 
1687
- startPromise
1688
- .then(async () => {
1697
+ (async () => {
1698
+ try {
1699
+ await startPromise;
1700
+
1689
1701
  switch (needsAutoboot) {
1690
1702
  case "boot":
1691
1703
  sthAutoboot.checked = true;
@@ -1726,11 +1738,11 @@ startPromise
1726
1738
  }
1727
1739
 
1728
1740
  go();
1729
- })
1730
- .catch((error) => {
1741
+ } catch (error) {
1731
1742
  console.error("Error initialising emulator:", error);
1732
1743
  showError("initialising", error);
1733
- });
1744
+ }
1745
+ })();
1734
1746
 
1735
1747
  const aysEl = document.getElementById("are-you-sure");
1736
1748
  const aysModal = new bootstrap.Modal(aysEl);