jsbeeb 1.17.0 → 1.18.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/src/main.js CHANGED
@@ -10,9 +10,9 @@ 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
- import { BbcDiscArchive, describe as describeHfe } from "./bbcdiscs.js";
15
+ import { BbcDiscArchive, Provenance, describe as describeHfe, matches, provenancesIn } from "./bbcdiscs.js";
16
16
  import { GamePad } from "./gamepads.js";
17
17
  import * as disc from "./fdc.js";
18
18
  import { loadTapeFromData } from "./tapes.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() : [];
@@ -399,6 +397,24 @@ function showError(context, error) {
399
397
 
400
398
  const errorText = (error) => error?.message ?? `${error}`;
401
399
 
400
+ function reportLoadFailure(description, error) {
401
+ console.error(`Error loading ${description}:`, error);
402
+ toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
403
+ }
404
+
405
+ function reportIgnoredFiles(name, ignored) {
406
+ if (!ignored.length) return;
407
+ toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
408
+ title: "Archive",
409
+ });
410
+ }
411
+
412
+ async function unzipAndReport(data) {
413
+ const unzipped = await utils.unzipDiscImage(data);
414
+ reportIgnoredFiles(unzipped.name, unzipped.ignored);
415
+ return unzipped;
416
+ }
417
+
402
418
  function showNotice(event) {
403
419
  const { message, title, quietKey } = event.detail;
404
420
  toast(message, { title, quietKey });
@@ -433,10 +449,25 @@ function putDiscIn(driveIndex, loadedDisc) {
433
449
  const was = drive.tracksPerStep;
434
450
  processor.fdc.loadDisc(driveIndex, loadedDisc, fixed);
435
451
  showDriveTracks(driveIndex);
452
+ noteUnsavedWrites(loadedDisc);
436
453
  // A switch the user fixed does not move, so anything it does is not news.
437
454
  if (fixed === undefined && drive.tracksPerStep !== was) noteDriveTracks(driveIndex, loadedDisc.name);
438
455
  }
439
456
 
457
+ let saidWritesAreNotKept = false;
458
+
459
+ function noteUnsavedWrites(loadedDisc) {
460
+ if (loadedDisc.savesChanges || saidWritesAreNotKept) return;
461
+ loadedDisc.notifyOnFirstTrackWrite(() => {
462
+ if (saidWritesAreNotKept) return;
463
+ saidWritesAreNotKept = true;
464
+ toast(`Changes to ${loadedDisc.name} are not saved. Use Discs, Download to keep a copy.`, {
465
+ title: "Disc",
466
+ quietKey: "quietDiscNotSaved",
467
+ });
468
+ });
469
+ }
470
+
440
471
  const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
441
472
 
442
473
  function showDriveTracks(driveIndex) {
@@ -458,59 +489,57 @@ function noteDriveTracks(driveIndex, discName) {
458
489
  });
459
490
  }
460
491
 
461
- function createCanvasForFilter(filterClass) {
492
+ // Test which filter is actually in use, not merely whether we got WebGL: a
493
+ // filter can decline a context that works perfectly well for other modes, in
494
+ // which case we are quietly left with an unfiltered display.
495
+ function reportAnyFallback(displayCanvas, filterClass) {
496
+ if (displayCanvas.filterClass === filterClass) return;
497
+ const reason = displayCanvas.fallbackReason ? ` (${displayCanvas.fallbackReason})` : "";
498
+ const { name } = filterClass.getDisplayConfig();
499
+ toast(`${name} is not available on this device, so the standard display is in use${reason}.`, {
500
+ title: "Display",
501
+ quietKey: "quietDisplayFallback",
502
+ });
503
+ }
504
+
505
+ function sizeCanvasFor(filterClass) {
462
506
  // Not `config`: that is the emulator's live configuration object, declared
463
507
  // at module scope and used throughout this file.
464
508
  const displayConfig = filterClass.getDisplayConfig();
465
- // Each mode says how many pixels it wants to draw into. Set this before
466
- // creating the context, which fixes its initial viewport.
509
+ if (screenCanvas.width === displayConfig.canvasWidth && screenCanvas.height === displayConfig.canvasHeight) return;
467
510
  screenCanvas.width = displayConfig.canvasWidth;
468
511
  screenCanvas.height = displayConfig.canvasHeight;
512
+ }
469
513
 
470
- const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
471
-
472
- // Test which filter was actually built, not merely whether we got WebGL: a
473
- // filter can decline a context that works perfectly well for other modes,
474
- // in which case bestCanvas quietly gives us an unfiltered GL canvas.
475
- if (newCanvas.filterClass !== filterClass) {
476
- const reason = newCanvas.fallbackReason ? ` (${newCanvas.fallbackReason})` : "";
477
- toast(`${displayConfig.name} is not available on this device, so the standard display is in use${reason}.`, {
478
- title: "Display",
479
- quietKey: "quietDisplayFallback",
480
- });
481
- }
514
+ function createCanvasForFilter(filterClass) {
515
+ // Each mode says how many pixels it wants to draw into. Set this before
516
+ // creating the context, which fixes its initial viewport.
517
+ sizeCanvasFor(filterClass);
482
518
 
519
+ const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
520
+ reportAnyFallback(newCanvas, filterClass);
483
521
  return newCanvas;
484
522
  }
485
523
 
486
524
  let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
487
525
  function swapCanvas(newFilterClass) {
488
- const oldCanvas = canvas;
489
- const newCanvas = createCanvasForFilter(newFilterClass);
490
- // Carry the picture over; the buffers differ in height, so copy what fits.
491
- newCanvas.fb32.set(oldCanvas.fb32.subarray(0, newCanvas.fb32.length));
492
- // Only once the replacement exists, so a failure to build it leaves the
493
- // display we already had. The two share a GL context but no GL objects.
494
- oldCanvas.dispose();
495
- video.fb32 = newCanvas.fb32;
496
- video.paint_ext = function paint(minx, miny, maxx, maxy) {
497
- frames++;
498
- if (frames < frameSkip) return;
499
- frames = 0;
500
- newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
501
- };
502
- canvas = newCanvas;
526
+ // Everything but the filter is the same whatever the mode: the framebuffer
527
+ // texture, the vertex buffers and fb32 all carry over untouched.
528
+ canvasLib.useBestFilter(canvas, newFilterClass);
529
+ reportAnyFallback(canvas, newFilterClass);
503
530
  // Follow the filter we ended up with, not the one we asked for: everything
504
- // downstream the monitor picture, the canvas geometry, how large a
505
- // drawing buffer to ask for comes from its display config.
506
- displayModeFilter = newCanvas.filterClass;
531
+ // downstream (the monitor picture, the canvas geometry, how large a drawing
532
+ // buffer to ask for) comes from its display config.
533
+ displayModeFilter = canvas.filterClass;
534
+ // Back to the mode's own size, undoing any scaling the last one asked for.
535
+ sizeCanvasFor(displayModeFilter);
507
536
  // Nothing else will redraw: the mode is changed from a modal, which stops
508
537
  // the emulator.
509
538
  video.paint();
510
539
  window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
511
540
  }
512
541
 
513
- let canvas = createCanvasForFilter(displayModeFilter);
542
+ const canvas = createCanvasForFilter(displayModeFilter);
514
543
  displayModeFilter = canvas.filterClass;
515
544
 
516
545
  video = new Video(
@@ -628,14 +657,19 @@ pastetext.addEventListener("dragover", function (event) {
628
657
  pastetext.addEventListener("drop", async function (event) {
629
658
  utils.noteEvent("local", "drop");
630
659
  const file = event.dataTransfer.files[0];
631
- const arrayBuffer = await file.arrayBuffer();
632
- if (isSnapshotFile(file.name, arrayBuffer)) {
633
- await loadStateFromFile(file, arrayBuffer);
634
- } else if (file.name.toLowerCase().endsWith(".uef")) {
635
- // Regular UEF tape image (not a BeebEm save state)
636
- setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
637
- } else {
638
- await loadHTMLFile(file);
660
+ if (!file) return;
661
+ try {
662
+ const arrayBuffer = await file.arrayBuffer();
663
+ if (isSnapshotFile(file.name, arrayBuffer)) {
664
+ await loadStateFromFile(file, arrayBuffer);
665
+ } else if (file.name.toLowerCase().endsWith(".uef")) {
666
+ // Regular UEF tape image (not a BeebEm save state)
667
+ setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
668
+ } else {
669
+ await loadHTMLFile(file);
670
+ }
671
+ } catch (error) {
672
+ reportLoadFailure(file.name, error);
639
673
  }
640
674
  });
641
675
 
@@ -725,17 +759,14 @@ if (config.hasEconet) {
725
759
  }
726
760
 
727
761
  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
- },
762
+ localStoragePersistence(
763
+ () => window.localStorage,
764
+ (error) =>
765
+ toast(
766
+ `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.`,
767
+ { title: "Settings", quietKey: "quietCmosSave" },
768
+ ),
769
+ ),
739
770
  model.cmosOverride,
740
771
  econet,
741
772
  );
@@ -747,12 +778,17 @@ function checkPrinterWindow() {
747
778
  if (printerWindow && !printerWindow.closed) return;
748
779
 
749
780
  printerWindow = window.open("", "_blank", "height=300,width=400");
781
+ if (!printerWindow) {
782
+ toast("The printer output window was blocked. Allow pop-up windows for this site, then press Ctrl-B again.", {
783
+ title: "Printer",
784
+ });
785
+ return;
786
+ }
750
787
  printerWindow.document.write(
751
788
  '<textarea id="text" rows="15" cols="40" placeholder="Printer outputs here..."></textarea>',
752
789
  );
753
790
  printerTextArea = printerWindow.document.getElementById("text");
754
-
755
- processor.uservia.setca1(true);
791
+ printerTextArea.value = printer.text;
756
792
  }
757
793
 
758
794
  const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
@@ -768,6 +804,8 @@ processor = new CpuClass(model, {
768
804
  econet,
769
805
  });
770
806
 
807
+ printer.attach(processor.uservia);
808
+
771
809
  processor.teletextAdaptor?.addEventListener("notice", showNotice);
772
810
 
773
811
  // Create input sources
@@ -1178,13 +1216,13 @@ async function hfeClick(file) {
1178
1216
  const name = describeHfe(file).title;
1179
1217
  popupLoading("Loading " + name);
1180
1218
  try {
1181
- const disc = await loadDiscImage(parsedQuery.disc1);
1182
- processor.fdc.loadDisc(0, disc);
1219
+ const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
1220
+ putDiscIn(0, loaded);
1183
1221
  loadingFinished();
1184
1222
  if (needsAutoboot) autoboot(name);
1185
1223
  } catch (err) {
1186
1224
  console.error("Error loading disc image:", err);
1187
- loadingFinished(err);
1225
+ loadingFinished(`Unable to load ${name} from the HFE archive: ${errorText(err)}`);
1188
1226
  }
1189
1227
  }
1190
1228
 
@@ -1194,13 +1232,15 @@ function hfeOnCat(catalogue) {
1194
1232
  const list = document.getElementById("hfe-list");
1195
1233
  document.querySelector("#hfe .loading").style.display = "none";
1196
1234
  const template = list.querySelector(".template");
1235
+ showProvenanceChoices(catalogue, onHfeFilter);
1197
1236
 
1198
1237
  const addSome = (remaining) => {
1199
1238
  if (ticket !== hfeRender) return;
1200
1239
  const MaxAtATime = 100;
1201
1240
  const Delay = 30;
1202
- // Read per batch: the filter can be typed into while this is still going.
1241
+ // Read per batch: both can be changed while this is still going.
1203
1242
  const filter = document.getElementById("hfe-filter").value.toLowerCase();
1243
+ const shown = shownProvenances();
1204
1244
  for (const file of remaining.slice(0, MaxAtATime)) {
1205
1245
  const { title, publisher, detail } = describeHfe(file);
1206
1246
  const row = template.cloneNode(true);
@@ -1208,6 +1248,8 @@ function hfeOnCat(catalogue) {
1208
1248
  row.querySelector(".name").textContent = title;
1209
1249
  row.querySelector(".publisher").textContent = publisher;
1210
1250
  row.querySelector(".detail").textContent = detail;
1251
+ row.querySelector(".provenance").textContent =
1252
+ file.provenance === Provenance.Reconstructed ? "reconstructed" : "";
1211
1253
  if (file.notes) row.title = file.notes;
1212
1254
  // The row is an anchor, and letting it navigate to "#" would push a
1213
1255
  // history entry of its own on top of the one updateUrl pushes.
@@ -1216,8 +1258,9 @@ function hfeOnCat(catalogue) {
1216
1258
  hfeClick(file);
1217
1259
  $hfeModal.hide();
1218
1260
  });
1219
- row.style.display = row.textContent.toLowerCase().includes(filter) ? "" : "none";
1261
+ row.hfeFile = file;
1220
1262
  list.appendChild(row);
1263
+ showHfeRow(row, file, filter, shown);
1221
1264
  }
1222
1265
  if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
1223
1266
  };
@@ -1231,7 +1274,53 @@ document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
1231
1274
  document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
1232
1275
 
1233
1276
  const hfeFilter = document.getElementById("hfe-filter");
1234
- const onHfeFilter = () => filterArchiveList("hfe-list", hfeFilter.value);
1277
+ const hfeProvenance = document.getElementById("hfe-provenance");
1278
+
1279
+ const HfeProvenanceLabels = {
1280
+ [Provenance.Captured]: ["Captured", "Direct from disc"],
1281
+ [Provenance.Reconstructed]: ["Reconstructed", "Rebuilt from a sector dump"],
1282
+ };
1283
+
1284
+ /** Which provenances the picker is showing, or null when it is not offering the choice. */
1285
+ const shownProvenances = () => {
1286
+ const boxes = [...hfeProvenance.querySelectorAll("input")];
1287
+ return boxes.length ? new Set(boxes.filter((box) => box.checked).map((box) => box.value)) : null;
1288
+ };
1289
+
1290
+ // Offer one tick per provenance the archive actually holds, rather than naming
1291
+ // them here: a source added later should appear without this having to change.
1292
+ function showProvenanceChoices(catalogue, onChange) {
1293
+ const present = provenancesIn(catalogue);
1294
+ // Nothing to choose between: no ticks, and shownProvenances says "all".
1295
+ if (present.length < 2) {
1296
+ hfeProvenance.replaceChildren();
1297
+ return;
1298
+ }
1299
+ const wasShown = shownProvenances();
1300
+ hfeProvenance.replaceChildren(
1301
+ ...present.map((provenance) => {
1302
+ const [text, why] = HfeProvenanceLabels[provenance] ?? [provenance, ""];
1303
+ const label = document.createElement("label");
1304
+ label.title = why;
1305
+ const box = document.createElement("input");
1306
+ box.type = "checkbox";
1307
+ box.value = provenance;
1308
+ box.checked = !wasShown || wasShown.has(provenance);
1309
+ box.addEventListener("change", onChange);
1310
+ label.append(box, text);
1311
+ return label;
1312
+ }),
1313
+ );
1314
+ }
1315
+
1316
+ const showHfeRow = (row, file, filter, shown) => (row.style.display = matches(file, filter, shown) ? "" : "none");
1317
+
1318
+ const onHfeFilter = () => {
1319
+ const filter = hfeFilter.value.toLowerCase();
1320
+ const shown = shownProvenances();
1321
+ for (const row of document.querySelectorAll("#hfe-list li:not(.template)"))
1322
+ showHfeRow(row, row.hfeFile, filter, shown);
1323
+ };
1235
1324
  hfeFilter.addEventListener("change", onHfeFilter);
1236
1325
  hfeFilter.addEventListener("keyup", onHfeFilter);
1237
1326
 
@@ -1352,7 +1441,12 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1352
1441
  discImage = split.image;
1353
1442
  const schema = split.schema;
1354
1443
  if (schema[0] === "!" || schema === "local") {
1355
- return disc.localDisc(processor.fdc, discImage, layout);
1444
+ return disc.localDisc(processor.fdc, discImage, layout, (error) =>
1445
+ toast(
1446
+ `Browser storage would not take changes to ${discImage} (${errorText(error)}). Use Discs, Download to keep a copy.`,
1447
+ { title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
1448
+ ),
1449
+ );
1356
1450
  }
1357
1451
  // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
1358
1452
  // Consider:
@@ -1363,12 +1457,13 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1363
1457
  switch (schema) {
1364
1458
  case "|":
1365
1459
  case "sth": {
1366
- const { name, data } = await discSth.fetch(discImage);
1460
+ const { name, data, ignored } = await discSth.fetch(discImage);
1461
+ reportIgnoredFiles(name, ignored);
1367
1462
  return disc.discFor(processor.fdc, name, data, undefined, layout);
1368
1463
  }
1369
1464
 
1370
1465
  case "hfe":
1371
- return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage));
1466
+ return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage), undefined, layout);
1372
1467
 
1373
1468
  case "gd": {
1374
1469
  const splat = discImage.match(/([^/]+)\/?(.*)/);
@@ -1384,7 +1479,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1384
1479
 
1385
1480
  case "data": {
1386
1481
  const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
1387
- const { name, data } = await utils.unzipDiscImage(arr);
1482
+ const { name, data } = await unzipAndReport(arr);
1388
1483
  return disc.discFor(processor.fdc, name, data, undefined, layout);
1389
1484
  }
1390
1485
  case "http":
@@ -1395,7 +1490,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1395
1490
  discImage = new URL(asUrl).pathname;
1396
1491
  let discData = await utils.loadData(asUrl);
1397
1492
  if (/\.zip/i.test(discImage)) {
1398
- const unzipped = await utils.unzipDiscImage(discData);
1493
+ const unzipped = await unzipAndReport(discData);
1399
1494
  discData = unzipped.data;
1400
1495
  discImage = unzipped.name;
1401
1496
  }
@@ -1414,13 +1509,14 @@ async function loadTapeImage(tapeImage) {
1414
1509
  switch (schema) {
1415
1510
  case "|":
1416
1511
  case "sth": {
1417
- const { name, data } = await tapeSth.fetch(tapeImage);
1512
+ const { name, data, ignored } = await tapeSth.fetch(tapeImage);
1513
+ reportIgnoredFiles(name, ignored);
1418
1514
  return await loadTapeFromData(name, data, model);
1419
1515
  }
1420
1516
 
1421
1517
  case "data": {
1422
1518
  const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
1423
- const { name, data } = await utils.unzipDiscImage(arr);
1519
+ const { name, data } = await unzipAndReport(arr);
1424
1520
  return await loadTapeFromData(name, data, model);
1425
1521
  }
1426
1522
 
@@ -1432,7 +1528,7 @@ async function loadTapeImage(tapeImage) {
1432
1528
  tapeImage = new URL(asUrl).pathname;
1433
1529
  let tapeData = await utils.loadData(asUrl);
1434
1530
  if (/\.zip/i.test(tapeImage)) {
1435
- const unzipped = await utils.unzipDiscImage(tapeData);
1531
+ const unzipped = await unzipAndReport(tapeData);
1436
1532
  tapeData = unzipped.data;
1437
1533
  tapeImage = unzipped.name;
1438
1534
  }
@@ -1444,7 +1540,7 @@ async function loadTapeImage(tapeImage) {
1444
1540
  let tapeData = await utils.loadData(tapePath);
1445
1541
  let tapeName = tapeImage;
1446
1542
  if (/\.zip/i.test(tapeName)) {
1447
- const unzipped = await utils.unzipDiscImage(tapeData);
1543
+ const unzipped = await unzipAndReport(tapeData);
1448
1544
  tapeData = unzipped.data;
1449
1545
  tapeName = unzipped.name;
1450
1546
  }
@@ -1457,7 +1553,11 @@ document.getElementById("disc_load").addEventListener("change", async function (
1457
1553
  if (evt.target.files.length === 0) return;
1458
1554
  utils.noteEvent("local", "click"); // NB no filename here
1459
1555
  const file = evt.target.files[0];
1460
- await loadHTMLFile(file);
1556
+ try {
1557
+ await loadHTMLFile(file);
1558
+ } catch (error) {
1559
+ reportLoadFailure(file.name, error);
1560
+ }
1461
1561
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1462
1562
  });
1463
1563
 
@@ -1465,7 +1565,11 @@ document.getElementById("fs_load").addEventListener("change", async function (ev
1465
1565
  if (evt.target.files.length === 0) return;
1466
1566
  utils.noteEvent("local", "click"); // NB no filename here
1467
1567
  const file = evt.target.files[0];
1468
- await loadSCSIFile(file);
1568
+ try {
1569
+ await loadSCSIFile(file);
1570
+ } catch (error) {
1571
+ reportLoadFailure(file.name, error);
1572
+ }
1469
1573
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1470
1574
  });
1471
1575
 
@@ -1474,17 +1578,21 @@ document.getElementById("tape_load").addEventListener("change", async function (
1474
1578
  const file = evt.target.files[0];
1475
1579
  utils.noteEvent("local", "clickTape"); // NB no filename here
1476
1580
 
1477
- let tapeData = await readFileAsBinaryString(file);
1478
- let tapeName = file.name;
1479
- if (/\.zip/i.test(tapeName)) {
1480
- const unzipped = await utils.unzipDiscImage(utils.stringToUint8Array(tapeData));
1481
- tapeData = unzipped.data;
1482
- tapeName = unzipped.name;
1581
+ try {
1582
+ let tapeData = await readFileAsBinaryString(file);
1583
+ let tapeName = file.name;
1584
+ if (/\.zip/i.test(tapeName)) {
1585
+ const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
1586
+ tapeData = unzipped.data;
1587
+ tapeName = unzipped.name;
1588
+ }
1589
+ setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1590
+ delete parsedQuery.tape;
1591
+ updateUrl();
1592
+ bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
1593
+ } catch (error) {
1594
+ reportLoadFailure(file.name, error);
1483
1595
  }
1484
- setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1485
- delete parsedQuery.tape;
1486
- updateUrl();
1487
- bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
1488
1596
 
1489
1597
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1490
1598
  });
@@ -1578,10 +1686,14 @@ async function gdLoad(cat, layout) {
1578
1686
 
1579
1687
  for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "none";
1580
1688
  (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);
1689
+ try {
1690
+ const available = await googleDrive.initialise();
1691
+ if (available) {
1692
+ for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
1693
+ await gdAuth(true);
1694
+ }
1695
+ } catch (error) {
1696
+ console.log(`Google Drive is unavailable: ${errorText(error)}`);
1585
1697
  }
1586
1698
  })();
1587
1699
  const googleDriveModal = new bootstrap.Modal(googleDriveEl);
@@ -1597,7 +1709,14 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
1597
1709
  gdLoading.textContent = "Loading...";
1598
1710
  gdLoading.style.display = "";
1599
1711
  for (const el of googleDriveEl.querySelectorAll("li:not(.template)")) el.remove();
1600
- const cat = await googleDrive.listFiles();
1712
+ let cat;
1713
+ try {
1714
+ cat = await googleDrive.listFiles();
1715
+ } catch (error) {
1716
+ console.error("Error listing Google Drive files:", error);
1717
+ gdLoading.textContent = `Unable to list your Google Drive files: ${errorText(error)}`;
1718
+ return;
1719
+ }
1601
1720
  const dbList = googleDriveEl.querySelector(".list");
1602
1721
  gdLoading.style.display = "none";
1603
1722
  const template = dbList.querySelector(".template");
@@ -1627,7 +1746,11 @@ for (const image of availableImages) {
1627
1746
  utils.noteEvent("images", "click", image.file);
1628
1747
  setDisc1Image(image.file);
1629
1748
  $discsModal.hide();
1630
- putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1749
+ try {
1750
+ putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1751
+ } catch (error) {
1752
+ reportLoadFailure(`${image.name} (${image.file})`, error);
1753
+ }
1631
1754
  });
1632
1755
  }
1633
1756
 
@@ -1920,8 +2043,7 @@ const startPromise = (async () => {
1920
2043
  try {
1921
2044
  await load();
1922
2045
  } catch (error) {
1923
- console.error(`Error loading ${description}:`, error);
1924
- toast(`Could not load ${description}: ${error?.message ?? error}`, { title: "Loading" });
2046
+ reportLoadFailure(description, error);
1925
2047
  }
1926
2048
  })();
1927
2049
  imageLoads.push(loading);
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
+ }