jsbeeb 1.17.0 → 1.17.1

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
@@ -13,6 +13,7 @@ different peripherals.
13
13
  - [Keyboard Mappings](#keyboard-mappings)
14
14
  - [Remapping Keys](#remapping-keys)
15
15
  - [Emulator Shortcuts](#emulator-shortcuts)
16
+ - [Printer Output](#printer-output)
16
17
  - [Save State and Rewind](#save-state-and-rewind)
17
18
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
18
19
  - [Running as a Desktop Application](#running-as-a-desktop-application)
@@ -103,8 +104,15 @@ The definitive lists are `keyCodes` (host) and `BBC` (BBC micro) in [`src/utils.
103
104
  | `Ctrl+Home` | Stop and enter debugger |
104
105
  | `Ctrl+Insert` | Toggle turbo (fast-as-possible) |
105
106
  | `Ctrl+End` | Pause emulation |
107
+ | `Ctrl+B` | Open printer output window |
106
108
  | `Alt+PageDown` | Open rewind scrubber |
107
109
 
110
+ ### Printer Output
111
+
112
+ Anything the machine prints is captured whether or not the printer window is open, so programs that print (with `VDU 2`, `*FX5,1` and the like) run rather than waiting for a printer that is not there.
113
+
114
+ Press `Ctrl+B` to open a window showing what has been printed so far; it then keeps up with the output as it arrives. Only the most recent output is kept, roughly a dozen pages' worth, so a program printing forever cannot fill memory.
115
+
108
116
  ### Save State and Rewind
109
117
 
110
118
  Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S` / `Ctrl+O` in the Electron app).
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.17.0",
10
+ "version": "1.17.1",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
@@ -30,6 +30,7 @@
30
30
  "argparse": "^3.0.0",
31
31
  "bootstrap": "^5.3.8",
32
32
  "bootswatch": "^5.3.8",
33
+ "pako": "^3.0.1",
33
34
  "sharp": "^0.35.3",
34
35
  "smoothie": "^1.36.1"
35
36
  },
package/src/6502.js CHANGED
@@ -1359,7 +1359,8 @@ export class Cpu6502 extends Base6502 {
1359
1359
  // Override in subclasses for different peripheral sets.
1360
1360
  // Only called on hard reset.
1361
1361
  resetPeripherals() {
1362
- if (this.config.printerPort) this.uservia.ca2changecallback = this.config.printerPort.outputStrobe;
1362
+ if (this.config.printerPort)
1363
+ this.uservia.ca2changecallback = (level, output) => this.config.printerPort.outputStrobe(level, output);
1363
1364
 
1364
1365
  this.sysvia.reset();
1365
1366
  this.uservia.reset();
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- import { decompress } from "./utils.js";
2
+ import { inflate } from "./utils.js";
3
3
 
4
4
  // B-em snapshot format parser (versions 1 and 3).
5
5
  // v1 (BEMSNAP1): Fixed-size 327,885 byte packed struct. Reference: beebjit state.c
@@ -334,7 +334,7 @@ async function parseBemTube(sections, tubeName) {
334
334
 
335
335
  // Those names come from a config file, so size is the real check. It cannot be exact: the
336
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");
337
+ const parasite = inflate(sections["P"].data);
338
338
  const romBytes = parasite.length - BemTubeRegisterBytes - BemTubeRamSize;
339
339
  if (romBytes < 0 || romBytes > BemTubeRamSize) {
340
340
  throw new Error(
@@ -449,9 +449,8 @@ async function parseBemV3(buffer) {
449
449
  let roms = null;
450
450
  const memSection = sections["M"];
451
451
  if (memSection) {
452
- // Memory is zlib-compressed; decompression is async.
453
452
  // Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
454
- const memData = await decompress(memSection.data, "deflate");
453
+ const memData = inflate(memSection.data);
455
454
  cpuState.fe30 = memData[0];
456
455
  cpuState.fe34 = memData[1];
457
456
  const ramStart = 2;
package/src/cmos.js CHANGED
@@ -33,6 +33,44 @@ function fromBcd(value) {
33
33
 
34
34
  export { defaultCmos };
35
35
 
36
+ /**
37
+ * CMOS persistence backed by a browser storage object, which can be unavailable, full or holding
38
+ * something other than what was last saved.
39
+ *
40
+ * @param {function(): Storage} getStorage typically `() => window.localStorage`. Reading that
41
+ * property is itself what throws when a page is refused storage, so it happens per call, inside
42
+ * the try.
43
+ * @param {function(*)} onSaveFailure called with the error the first time a save fails
44
+ */
45
+ export function localStoragePersistence(getStorage, onSaveFailure) {
46
+ let saveFailureReported = false;
47
+ return {
48
+ load() {
49
+ try {
50
+ const stored = getStorage().cmosRam;
51
+ if (!stored) return null;
52
+ const parsed = JSON.parse(stored);
53
+ if (!Array.isArray(parsed) || parsed.length !== defaultCmos.length)
54
+ throw new Error(`the stored settings are not ${defaultCmos.length} bytes`);
55
+ return parsed;
56
+ } catch (error) {
57
+ console.log(`Unable to read the stored CMOS settings: ${error?.message ?? error}`);
58
+ return null;
59
+ }
60
+ },
61
+ save(data) {
62
+ try {
63
+ getStorage().cmosRam = JSON.stringify(data);
64
+ } catch (error) {
65
+ console.log(`Unable to store the CMOS settings: ${error?.message ?? error}`);
66
+ if (saveFailureReported) return;
67
+ saveFailureReported = true;
68
+ onSaveFailure(error);
69
+ }
70
+ },
71
+ };
72
+ }
73
+
36
74
  export class Cmos {
37
75
  constructor(persistence, cmosOverride, econet) {
38
76
  this.store = persistence ? persistence.load() : null;
package/src/disc-hfe.js CHANGED
@@ -213,7 +213,7 @@ export function loadHfe(disc, data, onChange) {
213
213
  const hfeData = toHfe(disc);
214
214
  // Call the onChange handler with the updated HFE data
215
215
  onChange(hfeData);
216
- });
216
+ }, true);
217
217
  }
218
218
 
219
219
  return disc;
package/src/disc.js CHANGED
@@ -720,6 +720,7 @@ export function loadSsd(disc, data, isDsd, onChange) {
720
720
  if (!sectorShortfall(sector)) dataCopy.set(sector.sectorData, ssdOffsetOf(sector, side, numSides));
721
721
  onChange(dataCopy);
722
722
  },
723
+ true,
723
724
  );
724
725
  }
725
726
  return disc;
@@ -905,6 +906,7 @@ export class Disc {
905
906
  this.is40Track = false;
906
907
 
907
908
  this._trackWriteListeners = new Set();
909
+ this._savingListeners = new Set();
908
910
  this.isWriteable = isWriteable;
909
911
 
910
912
  // Track which tracks have been written since the last snapshot.
@@ -931,14 +933,32 @@ export class Disc {
931
933
  this.initSurface(0);
932
934
  }
933
935
 
934
- /** @param {function(boolean, Number, Track): void} listener called once per flushed track */
935
- addTrackWriteListener(listener) {
936
+ /**
937
+ * @param {function(boolean, Number, Track): void} listener called once per flushed track
938
+ * @param {boolean} [savesChanges] whether the listener puts the image somewhere it is read back from
939
+ */
940
+ addTrackWriteListener(listener, savesChanges = false) {
936
941
  this._trackWriteListeners.add(listener);
942
+ if (savesChanges) this._savingListeners.add(listener);
943
+ }
944
+
945
+ get savesChanges() {
946
+ return this._savingListeners.size > 0;
937
947
  }
938
948
 
939
949
  /** @param {function(boolean, Number, Track): void} listener */
940
950
  removeTrackWriteListener(listener) {
941
951
  this._trackWriteListeners.delete(listener);
952
+ this._savingListeners.delete(listener);
953
+ }
954
+
955
+ /** @param {function(): void} listener called once, when a track is first written to this disc */
956
+ notifyOnFirstTrackWrite(listener) {
957
+ const onFirstWrite = () => {
958
+ this.removeTrackWriteListener(onFirstWrite);
959
+ listener();
960
+ };
961
+ this.addTrackWriteListener(onFirstWrite);
942
962
  }
943
963
 
944
964
  /**
package/src/fdc.js CHANGED
@@ -289,7 +289,16 @@ export function discFor(fdc, name, stringData, onChange, layout = DiscLayout.aut
289
289
  return disc;
290
290
  }
291
291
 
292
- export function localDisc(fdc, name, layout = DiscLayout.auto) {
292
+ /**
293
+ * Create or open a disc held in the browser's local storage.
294
+ * @param {Object} fdc - The FDC controller object
295
+ * @param {string} name - The file name with extension
296
+ * @param {string} [layout] - one of DiscLayout; by default the image is asked what it is
297
+ * @param {function(*): void} [onSaveError] - called with whatever was thrown, the first time a write
298
+ * cannot be stored
299
+ * @returns {Disc} The loaded disc object
300
+ */
301
+ export function localDisc(fdc, name, layout = DiscLayout.auto, onSaveError = () => {}) {
293
302
  const discName = "disc_" + name;
294
303
  let data;
295
304
  const dataString = window.localStorage[discName];
@@ -307,12 +316,16 @@ export function localDisc(fdc, name, layout = DiscLayout.auto) {
307
316
  console.log("Loading browser-local disc " + name);
308
317
  data = utils.stringToUint8Array(dataString);
309
318
  }
319
+ let reportedSaveError = false;
310
320
  const onChange = (data) => {
311
321
  try {
312
322
  const str = utils.uint8ArrayToString(data);
313
323
  window.localStorage.setItem(discName, str);
314
324
  } catch (e) {
315
- window.alert("Writing to localStorage failed: " + e);
325
+ console.log(`Unable to save browser-local disc ${name}: ${e}`);
326
+ if (reportedSaveError) return;
327
+ reportedSaveError = true;
328
+ onSaveError(e);
316
329
  }
317
330
  };
318
331
  return discFor(fdc, name, data, onChange, layout);
@@ -49,10 +49,11 @@ export class GoogleDriveLoader {
49
49
 
50
50
  _loadScript(src) {
51
51
  // https://github.com/google/google-api-javascript-client/issues/319
52
- return new Promise((resolve) => {
52
+ return new Promise((resolve, reject) => {
53
53
  const script = document.createElement("script");
54
54
  script.src = src;
55
55
  script.onload = resolve;
56
+ script.onerror = () => reject(new Error(`Failed to fetch ${src}; a browser extension may be blocking it`));
56
57
  document.body.appendChild(script);
57
58
  });
58
59
  }
package/src/main.js CHANGED
@@ -10,7 +10,7 @@ import { Debugger } from "./web/debug.js";
10
10
  import { Cpu6502, AtomCpu6502 } from "./6502.js";
11
11
  import * as utils_atom from "./utils_atom.js";
12
12
  import { LoadSD } from "./mmc.js";
13
- import { Cmos } from "./cmos.js";
13
+ import { Cmos, localStoragePersistence } from "./cmos.js";
14
14
  import { StairwayToHell } from "./sth.js";
15
15
  import { BbcDiscArchive, describe as describeHfe } from "./bbcdiscs.js";
16
16
  import { GamePad } from "./gamepads.js";
@@ -31,6 +31,7 @@ import { GamepadSource } from "./gamepad-source.js";
31
31
  import { toast } from "./web/toast.js";
32
32
  import { MicrophoneInput } from "./microphone-input.js";
33
33
  import { SpeechOutput } from "./speech-output.js";
34
+ import { Printer } from "./printer.js";
34
35
  import { MouseJoystickSource } from "./mouse-joystick-source.js";
35
36
  import { calculateMouseCoordinates } from "./mouse-coordinates.js";
36
37
  import { getFilterForMode } from "./canvas.js";
@@ -208,19 +209,16 @@ if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilt
208
209
  if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
209
210
  if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
210
211
 
211
- const printerPort = {
212
- outputStrobe: function (level, output) {
213
- if (!printerTextArea) return;
214
- if (!output || level) return;
215
-
216
- const uservia = processor.uservia;
217
- // Ack the character by pulsing CA1 low.
218
- uservia.setca1(false);
219
- uservia.setca1(true);
220
- const newChar = String.fromCharCode(uservia.ora);
221
- printerTextArea.value += newChar;
212
+ const printer = new Printer({
213
+ onOutput: (char) => {
214
+ if (printerTextArea) printerTextArea.value += char;
222
215
  },
223
- };
216
+ onFirstOutput: () =>
217
+ toast("Printer output is being kept. Press Ctrl-B to open the printer window.", {
218
+ title: "Printer",
219
+ quietKey: "quietPrinterOutput",
220
+ }),
221
+ });
224
222
 
225
223
  // Accessibility switch state — bits 0-7 correspond to switches 1-8.
226
224
  // Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
@@ -335,7 +333,7 @@ const emulationConfig = {
335
333
  // before any the user asked for with ?rom=.
336
334
  extraRoms: [...config.extraRoms, ...extraRoms],
337
335
  userPort,
338
- printerPort,
336
+ printerPort: printer,
339
337
  getGamepads: function () {
340
338
  // Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
341
339
  return navigator.getGamepads ? navigator.getGamepads() : [];
@@ -433,10 +431,25 @@ function putDiscIn(driveIndex, loadedDisc) {
433
431
  const was = drive.tracksPerStep;
434
432
  processor.fdc.loadDisc(driveIndex, loadedDisc, fixed);
435
433
  showDriveTracks(driveIndex);
434
+ noteUnsavedWrites(loadedDisc);
436
435
  // A switch the user fixed does not move, so anything it does is not news.
437
436
  if (fixed === undefined && drive.tracksPerStep !== was) noteDriveTracks(driveIndex, loadedDisc.name);
438
437
  }
439
438
 
439
+ let saidWritesAreNotKept = false;
440
+
441
+ function noteUnsavedWrites(loadedDisc) {
442
+ if (loadedDisc.savesChanges || saidWritesAreNotKept) return;
443
+ loadedDisc.notifyOnFirstTrackWrite(() => {
444
+ if (saidWritesAreNotKept) return;
445
+ saidWritesAreNotKept = true;
446
+ toast(`Changes to ${loadedDisc.name} are not saved. Use Discs, Download to keep a copy.`, {
447
+ title: "Disc",
448
+ quietKey: "quietDiscNotSaved",
449
+ });
450
+ });
451
+ }
452
+
440
453
  const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
441
454
 
442
455
  function showDriveTracks(driveIndex) {
@@ -725,17 +738,14 @@ if (config.hasEconet) {
725
738
  }
726
739
 
727
740
  const cmos = new Cmos(
728
- {
729
- load: function () {
730
- if (window.localStorage.cmosRam) {
731
- return JSON.parse(window.localStorage.cmosRam);
732
- }
733
- return null;
734
- },
735
- save: function (data) {
736
- window.localStorage.cmosRam = JSON.stringify(data);
737
- },
738
- },
741
+ localStoragePersistence(
742
+ () => window.localStorage,
743
+ (error) =>
744
+ toast(
745
+ `Settings changed with *CONFIGURE will not be kept (${errorText(error)}). Check that this site is allowed to store data, and that its storage is not full.`,
746
+ { title: "Settings", quietKey: "quietCmosSave" },
747
+ ),
748
+ ),
739
749
  model.cmosOverride,
740
750
  econet,
741
751
  );
@@ -747,12 +757,17 @@ function checkPrinterWindow() {
747
757
  if (printerWindow && !printerWindow.closed) return;
748
758
 
749
759
  printerWindow = window.open("", "_blank", "height=300,width=400");
760
+ if (!printerWindow) {
761
+ toast("The printer output window was blocked. Allow pop-up windows for this site, then press Ctrl-B again.", {
762
+ title: "Printer",
763
+ });
764
+ return;
765
+ }
750
766
  printerWindow.document.write(
751
767
  '<textarea id="text" rows="15" cols="40" placeholder="Printer outputs here..."></textarea>',
752
768
  );
753
769
  printerTextArea = printerWindow.document.getElementById("text");
754
-
755
- processor.uservia.setca1(true);
770
+ printerTextArea.value = printer.text;
756
771
  }
757
772
 
758
773
  const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
@@ -768,6 +783,8 @@ processor = new CpuClass(model, {
768
783
  econet,
769
784
  });
770
785
 
786
+ printer.attach(processor.uservia);
787
+
771
788
  processor.teletextAdaptor?.addEventListener("notice", showNotice);
772
789
 
773
790
  // Create input sources
@@ -1178,13 +1195,13 @@ async function hfeClick(file) {
1178
1195
  const name = describeHfe(file).title;
1179
1196
  popupLoading("Loading " + name);
1180
1197
  try {
1181
- const disc = await loadDiscImage(parsedQuery.disc1);
1182
- processor.fdc.loadDisc(0, disc);
1198
+ const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
1199
+ putDiscIn(0, loaded);
1183
1200
  loadingFinished();
1184
1201
  if (needsAutoboot) autoboot(name);
1185
1202
  } catch (err) {
1186
1203
  console.error("Error loading disc image:", err);
1187
- loadingFinished(err);
1204
+ loadingFinished(`Unable to load ${name} from the HFE archive: ${errorText(err)}`);
1188
1205
  }
1189
1206
  }
1190
1207
 
@@ -1352,7 +1369,12 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1352
1369
  discImage = split.image;
1353
1370
  const schema = split.schema;
1354
1371
  if (schema[0] === "!" || schema === "local") {
1355
- return disc.localDisc(processor.fdc, discImage, layout);
1372
+ return disc.localDisc(processor.fdc, discImage, layout, (error) =>
1373
+ toast(
1374
+ `Browser storage would not take changes to ${discImage} (${errorText(error)}). Use Discs, Download to keep a copy.`,
1375
+ { title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
1376
+ ),
1377
+ );
1356
1378
  }
1357
1379
  // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
1358
1380
  // Consider:
@@ -1368,7 +1390,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1368
1390
  }
1369
1391
 
1370
1392
  case "hfe":
1371
- return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage));
1393
+ return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage), undefined, layout);
1372
1394
 
1373
1395
  case "gd": {
1374
1396
  const splat = discImage.match(/([^/]+)\/?(.*)/);
@@ -1578,10 +1600,14 @@ async function gdLoad(cat, layout) {
1578
1600
 
1579
1601
  for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "none";
1580
1602
  (async () => {
1581
- const available = await googleDrive.initialise();
1582
- if (available) {
1583
- for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
1584
- await gdAuth(true);
1603
+ try {
1604
+ const available = await googleDrive.initialise();
1605
+ if (available) {
1606
+ for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
1607
+ await gdAuth(true);
1608
+ }
1609
+ } catch (error) {
1610
+ console.log(`Google Drive is unavailable: ${errorText(error)}`);
1585
1611
  }
1586
1612
  })();
1587
1613
  const googleDriveModal = new bootstrap.Modal(googleDriveEl);
package/src/printer.js ADDED
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Centronics printer attached to the user VIA: acknowledges each character and keeps the most
5
+ * recent output so it can be shown whenever a printer window is opened.
6
+ */
7
+
8
+ export const MaxBufferedChars = 64 * 1024;
9
+
10
+ export class Printer {
11
+ /**
12
+ * @param {object} [handlers]
13
+ * @param {function(string): void} [handlers.onOutput] called with each character as it is printed
14
+ * @param {function(): void} [handlers.onFirstOutput] called once, when the machine first prints
15
+ */
16
+ constructor({ onOutput = () => {}, onFirstOutput = () => {} } = {}) {
17
+ this._onOutput = onOutput;
18
+ this._onFirstOutput = onFirstOutput;
19
+ this._uservia = null;
20
+ this._olderText = "";
21
+ this._newerText = "";
22
+ this._hasPrinted = false;
23
+ }
24
+
25
+ outputStrobe(level, output) {
26
+ if (!output || level) return;
27
+
28
+ const uservia = this._uservia;
29
+ // Ack the character by pulsing CA1 low.
30
+ uservia.setca1(false);
31
+ uservia.setca1(true);
32
+ this._append(String.fromCharCode(uservia.ora));
33
+ }
34
+
35
+ attach(uservia) {
36
+ this._uservia = uservia;
37
+ // The ack line idles high, so that each ack is a falling edge.
38
+ uservia.setca1(true);
39
+ }
40
+
41
+ /** The most recent output, at most MaxBufferedChars of it. */
42
+ get text() {
43
+ return (this._olderText + this._newerText).slice(-MaxBufferedChars);
44
+ }
45
+
46
+ _append(char) {
47
+ // Two chunks so the bound costs a trim when the text is read, not one per character.
48
+ this._newerText += char;
49
+ if (this._newerText.length >= MaxBufferedChars) {
50
+ this._olderText = this._newerText;
51
+ this._newerText = "";
52
+ }
53
+ if (!this._hasPrinted) {
54
+ this._hasPrinted = true;
55
+ this._onFirstOutput();
56
+ }
57
+ this._onOutput(char);
58
+ }
59
+ }
package/src/utils.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
- // Minimal ZIP extractor using native DecompressionStream for deflate.
3
- // Supports methods 0 (stored) and 8 (deflate); other methods (bzip2,
4
- // lzma, etc.) will throw an error with the method number.
2
+ // Minimal ZIP extractor. Supports methods 0 (stored) and 8 (deflate); other
3
+ // methods (bzip2, lzma, etc.) will throw an error with the method number.
4
+
5
+ import { inflate as pakoInflate, inflateRaw as pakoInflateRaw, ungzip as pakoUngzip } from "pako";
5
6
 
6
7
  const ZipLocalHeaderSig = 0x04034b50;
7
8
  const ZipCentralDirSig = 0x02014b50;
@@ -35,45 +36,17 @@ function findEocd(buf) {
35
36
  throw new Error("Not a ZIP file: EOCD not found");
36
37
  }
37
38
 
38
- // Pipe data through a DecompressionStream and return the result.
39
- // Starts the read loop before writing to avoid backpressure deadlock.
40
- // On error, Node's DecompressionStream rejects multiple internal promises
41
- // (write, close, and closed); we catch the write side to prevent unhandled
42
- // rejections and let the error surface through the read side.
43
- export async function decompress(data, format) {
44
- const ds = new DecompressionStream(format);
45
- const writer = ds.writable.getWriter();
46
- const reader = ds.readable.getReader();
47
- const chunks = [];
48
- const readPromise = (async () => {
49
- for (;;) {
50
- const { done, value } = await reader.read();
51
- if (done) break;
52
- chunks.push(value);
53
- }
54
- })();
55
- const writePromise = (async () => {
56
- await writer.write(data);
57
- await writer.close();
58
- })().catch(() => {
59
- // Intentionally empty.
60
- });
61
- // Intentionally empty: Node's DecompressionStream rejects multiple
62
- // promises on error (write, close, closed). The read side surfaces
63
- // the same error with proper context — catching these just prevents
64
- // unhandled rejections from the write-side promises.
65
- writer.closed.catch(() => {});
66
- await readPromise;
67
- await writePromise;
68
- if (chunks.length === 1) return chunks[0];
69
- const totalLen = chunks.reduce((s, c) => s + c.length, 0);
70
- const out = new Uint8Array(totalLen);
71
- let offset = 0;
72
- for (const chunk of chunks) {
73
- out.set(chunk, offset);
74
- offset += chunk.length;
39
+ function inflateWith(inflater, data, context) {
40
+ if (!(data instanceof Uint8Array)) data = new Uint8Array(data);
41
+ try {
42
+ return inflater(data);
43
+ } catch (cause) {
44
+ throw new Error(`${context}: ${cause.message || cause}`, { cause });
75
45
  }
76
- return out;
46
+ }
47
+
48
+ export function inflate(data) {
49
+ return inflateWith(pakoInflate, data, "Unable to inflate");
77
50
  }
78
51
 
79
52
  // Extract all files from a ZIP archive. Returns {filename: Uint8Array}.
@@ -92,6 +65,7 @@ export async function unzip(buf) {
92
65
  const flags = readU16(buf, pos + 8);
93
66
  if (flags & 0x0001) throw new Error("Encrypted ZIP entries are not supported");
94
67
  const method = readU16(buf, pos + 10);
68
+ const expectedCrc = readU32(buf, pos + 16);
95
69
  const compressedSize = readU32(buf, pos + 20);
96
70
  const nameLen = readU16(buf, pos + 28);
97
71
  const extraLen = readU16(buf, pos + 30);
@@ -112,25 +86,25 @@ export async function unzip(buf) {
112
86
  if (method === ZipMethodStored) {
113
87
  files[name] = raw.slice();
114
88
  } else if (method === ZipMethodDeflate) {
115
- files[name] = await decompress(raw, "deflate-raw");
89
+ files[name] = inflateWith(pakoInflateRaw, raw, `Unable to inflate ZIP entry ${name}`);
116
90
  } else {
117
91
  throw new Error(`Unsupported ZIP compression method ${method} for ${name}`);
118
92
  }
93
+ if (crc32(files[name]) >>> 0 !== expectedCrc) throw new Error(`Corrupt ZIP entry ${name}: CRC32 mismatch`);
119
94
  }
120
95
  return files;
121
96
  }
122
97
 
123
98
  // Standard CRC-32/ISO-HDLC.
99
+ const Crc32Table = new Uint32Array(256).map((_, index) => {
100
+ let crc = index;
101
+ for (let bit = 0; bit < 8; ++bit) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
102
+ return crc;
103
+ });
104
+
124
105
  export function crc32(data) {
125
106
  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
- }
107
+ for (let i = 0; i < data.length; ++i) crc = (crc >>> 8) ^ Crc32Table[(crc ^ data[i]) & 0xff];
134
108
  return ~crc;
135
109
  }
136
110
 
@@ -1112,11 +1086,7 @@ export function readFloat32(data, offset) {
1112
1086
  }
1113
1087
 
1114
1088
  export async function ungzip(data) {
1115
- try {
1116
- return await decompress(data, "gzip");
1117
- } catch (cause) {
1118
- throw new Error("Unable to ungzip: " + (cause.message || cause), { cause });
1119
- }
1089
+ return inflateWith(pakoUngzip, data, "Unable to ungzip");
1120
1090
  }
1121
1091
 
1122
1092
  export class DataStream {
package/src/via.js CHANGED
@@ -154,6 +154,11 @@ class Via {
154
154
  }
155
155
  this.updateIFR();
156
156
 
157
+ // The strobe is "data ready": the port settles before CA2 falls.
158
+ // http://archive.6502.org/datasheets/wdc_w65c22s_mar_2004.pdf figures 3-6 and 3-7.
159
+ this.ora = val;
160
+ this.recalculatePortAPins();
161
+
157
162
  mode = this.pcr & 0x0e;
158
163
  if (mode === 8) {
159
164
  // Handshake mode
@@ -163,7 +168,8 @@ class Via {
163
168
  this.setca2(false);
164
169
  this.setca2(true);
165
170
  }
166
- /* falls through */
171
+ break;
172
+
167
173
  case ORAnh:
168
174
  this.ora = val;
169
175
  this.recalculatePortAPins();
@@ -4,6 +4,7 @@ import { RelayNoise, FakeRelayNoise } from "../relaynoise.js";
4
4
  import { Music5000, FakeMusic5000 } from "../music5000.js";
5
5
  import { createAudioContext } from "../audio-utils.js";
6
6
  import { toggle, fadeIn, fadeOut } from "../dom-utils.js";
7
+ import { toast } from "./toast.js";
7
8
 
8
9
  // Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit.
9
10
  // See https://github.com/vitejs/vite/discussions/6459
@@ -15,7 +16,7 @@ export class AudioHandler {
15
16
  this.cpuSpeed = cpuSpeed;
16
17
  this.isAtom = isAtom;
17
18
  this.warningNode = warningNode;
18
- this.noAudioWorklet = false;
19
+ this.noAudio = false;
19
20
  toggle(this.warningNode, false);
20
21
  this.stats = {};
21
22
  if (statsNode) {
@@ -38,11 +39,11 @@ export class AudioHandler {
38
39
  this.masterGain.connect(this.audioContext.destination);
39
40
  this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
40
41
  this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
41
- this._setup(audioFilterFreq, audioFilterQ).then();
42
+ this._setup(audioFilterFreq, audioFilterQ).catch((error) => this._audioUnavailable(error));
42
43
  } else {
43
44
  if (this.audioContext && !this.audioContext.audioWorklet) {
44
45
  this.audioContext = null;
45
- this.noAudioWorklet = true;
46
+ this.noAudio = true;
46
47
  console.log("Unable to initialise audio: no audio worklet API");
47
48
  const localhost = new URL(window.location);
48
49
  localhost.hostname = "localhost";
@@ -66,12 +67,21 @@ export class AudioHandler {
66
67
  this.audioContextM5000.onstatechange = () => this.checkStatus();
67
68
  this.music5000 = new Music5000((buffer) => this._onBufferMusic5000(buffer));
68
69
 
69
- this.audioContextM5000.audioWorklet.addModule(music5000WorkletUrl).then(() => {
70
- this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
71
- outputChannelCount: [2],
70
+ this.audioContextM5000.audioWorklet
71
+ .addModule(music5000WorkletUrl)
72
+ .then(() => {
73
+ this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
74
+ outputChannelCount: [2],
75
+ });
76
+ this._music5000workletnode.connect(this.audioContextM5000.destination);
77
+ })
78
+ .catch((error) => {
79
+ console.error("Unable to initialise Music 5000 audio", error);
80
+ toast(
81
+ `The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
82
+ { title: "Music 5000", quietKey: "quietMusic5000Audio" },
83
+ );
72
84
  });
73
- this._music5000workletnode.connect(this.audioContextM5000.destination);
74
- });
75
85
  } else {
76
86
  this.music5000 = new FakeMusic5000();
77
87
  }
@@ -116,6 +126,13 @@ export class AudioHandler {
116
126
  };
117
127
  }
118
128
 
129
+ _audioUnavailable(error) {
130
+ console.error("Unable to initialise audio", error);
131
+ this.noAudio = true;
132
+ this.warningNode.textContent = `There will be no sound: the audio system could not be started (${error?.message ?? error}). Reloading the page may help.`;
133
+ fadeIn(this.warningNode);
134
+ }
135
+
119
136
  _addStat(stat, info) {
120
137
  const timeSeries = new this._TimeSeries();
121
138
  this.stats[stat] = timeSeries;
@@ -146,7 +163,7 @@ export class AudioHandler {
146
163
  }
147
164
 
148
165
  checkStatus() {
149
- if (this.noAudioWorklet) return;
166
+ if (this.noAudio) return;
150
167
  if (!this.audioContext && !this.audioContextM5000) return;
151
168
  const suspended =
152
169
  (this.audioContext && this.audioContext.state === "suspended") ||
package/src/web/toast.js CHANGED
@@ -73,7 +73,11 @@ export function toast(message, { title = "", quietKey = "" } = {}) {
73
73
  quiet.querySelector("input").addEventListener("change", (event) => remember(quietKey, event.target.checked));
74
74
 
75
75
  toastContainer().appendChild(element);
76
- element.addEventListener("hidden.bs.toast", () => element.remove());
76
+ // Bootstrap keys its instances by element, so one only removed stays held.
77
+ element.addEventListener("hidden.bs.toast", () => {
78
+ bootstrap.Toast.getInstance(element)?.dispose();
79
+ element.remove();
80
+ });
77
81
  bootstrap.Toast.getOrCreateInstance(element).show();
78
82
  return element;
79
83
  }