jsbeeb 1.16.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/src/main.js CHANGED
@@ -10,8 +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
16
  import { GamePad } from "./gamepads.js";
16
17
  import * as disc from "./fdc.js";
17
18
  import { loadTapeFromData } from "./tapes.js";
@@ -19,16 +20,18 @@ import { GoogleDriveLoader } from "./google-drive.js";
19
20
  import * as tokeniser from "./basic-tokenise.js";
20
21
  import * as canvasLib from "./canvas.js";
21
22
  import { Config } from "./config.js";
22
- import { tubeModelFor } from "./models.js";
23
+ import { DefaultModel, findModel, tubeModelFor } from "./models.js";
23
24
  import { initialise as electron } from "./app/electron.js";
24
25
  import { AudioHandler } from "./web/audio-handler.js";
25
26
  import { Econet } from "./econet.js";
26
- import { toSsdOrDsd } from "./disc.js";
27
+ import { DiscLayout, toSsdOrDsd } from "./disc.js";
27
28
  import { toHfe } from "./disc-hfe.js";
28
29
  import { Keyboard } from "./keyboard.js";
29
30
  import { GamepadSource } from "./gamepad-source.js";
31
+ import { toast } from "./web/toast.js";
30
32
  import { MicrophoneInput } from "./microphone-input.js";
31
33
  import { SpeechOutput } from "./speech-output.js";
34
+ import { Printer } from "./printer.js";
32
35
  import { MouseJoystickSource } from "./mouse-joystick-source.js";
33
36
  import { calculateMouseCoordinates } from "./mouse-coordinates.js";
34
37
  import { getFilterForMode } from "./canvas.js";
@@ -48,11 +51,13 @@ import { DiscVisualiser } from "./disc-visualiser.js";
48
51
  import { downloadBlob } from "./dom-utils.js";
49
52
  import {
50
53
  buildUrlFromParams,
54
+ DriveTracks,
51
55
  guessModelFromHostname,
52
56
  ParamTypes,
53
57
  parseMediaParams,
54
58
  parseQueryString,
55
59
  processAutobootParams,
60
+ processDriveTrackParams,
56
61
  processInputParams,
57
62
  } from "./url-params.js";
58
63
 
@@ -65,6 +70,7 @@ let frameSkip = 0;
65
70
  let syncLights;
66
71
  let discSth;
67
72
  let tapeSth;
73
+ let hfeArchive;
68
74
  let running;
69
75
  let model;
70
76
 
@@ -153,6 +159,8 @@ const paramTypes = {
153
159
  keyLayout: ParamTypes.STRING,
154
160
  autotype: ParamTypes.STRING,
155
161
  displayMode: ParamTypes.STRING,
162
+ drive0Tracks: ParamTypes.STRING,
163
+ drive1Tracks: ParamTypes.STRING,
156
164
  };
157
165
 
158
166
  // Parse the query string with parameter types
@@ -173,6 +181,7 @@ let econet = null;
173
181
 
174
182
  // Parse disc and tape images from query parameters
175
183
  const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
184
+ const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
176
185
 
177
186
  // Only assign if values are provided
178
187
  if (queryDiscImage) discImage = queryDiscImage;
@@ -200,19 +209,16 @@ if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilt
200
209
  if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
201
210
  if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
202
211
 
203
- const printerPort = {
204
- outputStrobe: function (level, output) {
205
- if (!printerTextArea) return;
206
- if (!output || level) return;
207
-
208
- const uservia = processor.uservia;
209
- // Ack the character by pulsing CA1 low.
210
- uservia.setca1(false);
211
- uservia.setca1(true);
212
- const newChar = String.fromCharCode(uservia.ora);
213
- printerTextArea.value += newChar;
212
+ const printer = new Printer({
213
+ onOutput: (char) => {
214
+ if (printerTextArea) printerTextArea.value += char;
214
215
  },
215
- };
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
+ });
216
222
 
217
223
  // Accessibility switch state — bits 0-7 correspond to switches 1-8.
218
224
  // Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
@@ -282,7 +288,13 @@ const config = new Config(
282
288
  // Perform mapping of legacy models to the new format
283
289
  config.mapLegacyModels(parsedQuery);
284
290
 
285
- config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
291
+ const requestedModelName = parsedQuery.model || guessModelFromHostname(window.location.hostname);
292
+ const requestedModel = findModel(requestedModelName);
293
+ if (!requestedModel)
294
+ toast(`There is no model called "${requestedModelName}". Using ${DefaultModel.name} instead.`, {
295
+ title: "Model",
296
+ });
297
+ config.setModel((requestedModel ?? DefaultModel).name);
286
298
  config.setKeyLayout(keyLayout);
287
299
  config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
288
300
  config.setMicrophoneChannel(parsedQuery.microphoneChannel);
@@ -321,7 +333,7 @@ const emulationConfig = {
321
333
  // before any the user asked for with ?rom=.
322
334
  extraRoms: [...config.extraRoms, ...extraRoms],
323
335
  userPort,
324
- printerPort,
336
+ printerPort: printer,
325
337
  getGamepads: function () {
326
338
  // Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
327
339
  return navigator.getGamepads ? navigator.getGamepads() : [];
@@ -383,8 +395,80 @@ function showError(context, error) {
383
395
  errorDialogModal.show();
384
396
  }
385
397
 
398
+ const errorText = (error) => error?.message ?? `${error}`;
399
+
400
+ function showNotice(event) {
401
+ const { message, title, quietKey } = event.detail;
402
+ toast(message, { title, quietKey });
403
+ }
404
+
386
405
  if (keyMappingWarnings.length) {
387
- showError("applying the key mappings in the URL", keyMappingWarnings.join(" "));
406
+ toast(`${keyMappingWarnings.join(" ")} The key names are listed in the README.`, {
407
+ title: "Mappings in the URL",
408
+ });
409
+ }
410
+
411
+ if (driveTrackWarnings.length) {
412
+ toast(`${driveTrackWarnings.join(" ")} Auto is in use instead; pick 40 or 80 from the Discs menu.`, {
413
+ title: "Disc drives",
414
+ });
415
+ }
416
+
417
+ /** @returns {string} the DiscLayout to load an image for this drive with */
418
+ function layoutForDrive(driveIndex) {
419
+ return driveTracks[driveIndex] === DriveTracks.eighty ? DiscLayout.contiguous : DiscLayout.auto;
420
+ }
421
+
422
+ /** @returns {Number|undefined} the tracksPerStep the user fixed this drive at, if they fixed one */
423
+ function tracksPerStepForDrive(driveIndex) {
424
+ if (driveTracks[driveIndex] === DriveTracks.auto) return undefined;
425
+ return driveTracks[driveIndex] === DriveTracks.forty ? 2 : 1;
426
+ }
427
+
428
+ function putDiscIn(driveIndex, loadedDisc) {
429
+ const drive = processor.fdc.drives[driveIndex];
430
+ const fixed = tracksPerStepForDrive(driveIndex);
431
+ const was = drive.tracksPerStep;
432
+ processor.fdc.loadDisc(driveIndex, loadedDisc, fixed);
433
+ showDriveTracks(driveIndex);
434
+ noteUnsavedWrites(loadedDisc);
435
+ // A switch the user fixed does not move, so anything it does is not news.
436
+ if (fixed === undefined && drive.tracksPerStep !== was) noteDriveTracks(driveIndex, loadedDisc.name);
437
+ }
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
+
453
+ const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
454
+
455
+ function showDriveTracks(driveIndex) {
456
+ const drive = processor.fdc?.drives[driveIndex];
457
+ if (!drive) return;
458
+ for (const button of driveTracksButtons(driveIndex))
459
+ button.classList.toggle("active", tracksPerStepFor(button.dataset.tracks) === drive.tracksPerStep);
460
+ }
461
+
462
+ function driveTracksButtons(driveIndex) {
463
+ return document.querySelectorAll(`.drive-tracks[data-drive="${driveIndex}"] [data-tracks]`);
464
+ }
465
+
466
+ function noteDriveTracks(driveIndex, discName) {
467
+ const tracks = processor.fdc.drives[driveIndex].tracksPerStep === 2 ? "40" : "80";
468
+ toast(`Drive ${driveIndex} switched to ${tracks} track for ${discName}.`, {
469
+ title: "Disc drive",
470
+ quietKey: "quietDriveTracks",
471
+ });
388
472
  }
389
473
 
390
474
  function createCanvasForFilter(filterClass) {
@@ -402,10 +486,11 @@ function createCanvasForFilter(filterClass) {
402
486
  // filter can decline a context that works perfectly well for other modes,
403
487
  // in which case bestCanvas quietly gives us an unfiltered GL canvas.
404
488
  if (newCanvas.filterClass !== filterClass) {
405
- showError(
406
- `enabling ${displayConfig.name} mode`,
407
- `${displayConfig.name} is not available on this device. Using standard display instead.`,
408
- );
489
+ const reason = newCanvas.fallbackReason ? ` (${newCanvas.fallbackReason})` : "";
490
+ toast(`${displayConfig.name} is not available on this device, so the standard display is in use${reason}.`, {
491
+ title: "Display",
492
+ quietKey: "quietDisplayFallback",
493
+ });
409
494
  }
410
495
 
411
496
  return newCanvas;
@@ -516,10 +601,10 @@ function downloadDriveData(data, name, extension) {
516
601
 
517
602
  async function loadHTMLFile(file) {
518
603
  const imageData = utils.stringToUint8Array(await readFileAsBinaryString(file));
519
- const loadedDisc = disc.discFor(processor.fdc, file.name, imageData);
604
+ const loadedDisc = disc.discFor(processor.fdc, file.name, imageData, undefined, layoutForDrive(0));
520
605
  // Local file: retain the image bytes for embedding in save-to-file snapshots.
521
606
  loadedDisc.setOriginalImage(imageData);
522
- processor.fdc.loadDisc(0, loadedDisc);
607
+ putDiscIn(0, loadedDisc);
523
608
  delete parsedQuery.disc;
524
609
  delete parsedQuery.disc1;
525
610
  updateUrl();
@@ -653,17 +738,14 @@ if (config.hasEconet) {
653
738
  }
654
739
 
655
740
  const cmos = new Cmos(
656
- {
657
- load: function () {
658
- if (window.localStorage.cmosRam) {
659
- return JSON.parse(window.localStorage.cmosRam);
660
- }
661
- return null;
662
- },
663
- save: function (data) {
664
- window.localStorage.cmosRam = JSON.stringify(data);
665
- },
666
- },
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
+ ),
667
749
  model.cmosOverride,
668
750
  econet,
669
751
  );
@@ -675,12 +757,17 @@ function checkPrinterWindow() {
675
757
  if (printerWindow && !printerWindow.closed) return;
676
758
 
677
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
+ }
678
766
  printerWindow.document.write(
679
767
  '<textarea id="text" rows="15" cols="40" placeholder="Printer outputs here..."></textarea>',
680
768
  );
681
769
  printerTextArea = printerWindow.document.getElementById("text");
682
-
683
- processor.uservia.setca1(true);
770
+ printerTextArea.value = printer.text;
684
771
  }
685
772
 
686
773
  const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
@@ -696,14 +783,16 @@ processor = new CpuClass(model, {
696
783
  econet,
697
784
  });
698
785
 
699
- processor.teletextAdaptor?.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
786
+ printer.attach(processor.uservia);
787
+
788
+ processor.teletextAdaptor?.addEventListener("notice", showNotice);
700
789
 
701
790
  // Create input sources
702
791
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
703
792
  // Create MicrophoneInput but don't enable by default
704
793
  const microphoneInput = new MicrophoneInput();
705
794
  microphoneInput.setErrorCallback((message) => {
706
- showError("accessing microphone", message);
795
+ toast(`${message} The microphone channel has been turned off.`, { title: "Microphone" });
707
796
  });
708
797
 
709
798
  // Create MouseJoystickSource but don't enable by default
@@ -804,7 +893,7 @@ keyboard = new Keyboard({
804
893
  keyLayout,
805
894
  dbgr,
806
895
  });
807
- keyboard.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
896
+ keyboard.addEventListener("notice", showNotice);
808
897
  keyboard.addEventListener("pause", () => stop(false));
809
898
  keyboard.addEventListener("resume", () => go());
810
899
  keyboard.addEventListener("break", (e) => {
@@ -939,15 +1028,26 @@ function setTapeImage(name) {
939
1028
  config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
940
1029
  }
941
1030
 
942
- function sthClearList() {
943
- for (const el of document.querySelectorAll("#sth-list li:not(.template)")) el.remove();
1031
+ function clearArchiveList(listId) {
1032
+ for (const el of document.querySelectorAll(`#${listId} li:not(.template)`)) el.remove();
1033
+ }
1034
+
1035
+ function showArchiveMessage(modalId, listId, message) {
1036
+ const loading = document.querySelector(`#${modalId} .loading`);
1037
+ loading.textContent = message;
1038
+ loading.style.display = "";
1039
+ clearArchiveList(listId);
1040
+ }
1041
+
1042
+ function filterArchiveList(listId, filter) {
1043
+ filter = filter.toLowerCase();
1044
+ for (const el of document.querySelectorAll(`#${listId} li:not(.template)`)) {
1045
+ el.style.display = el.textContent.toLowerCase().includes(filter) ? "" : "none";
1046
+ }
944
1047
  }
945
1048
 
946
1049
  function sthStartLoad() {
947
- const sthLoading = document.querySelector("#sth .loading");
948
- sthLoading.textContent = "Loading catalog from STH archive";
949
- sthLoading.style.display = "";
950
- sthClearList();
1050
+ showArchiveMessage("sth", "sth-list", "Loading catalog from STH archive");
951
1051
  }
952
1052
 
953
1053
  async function discSthClick(item) {
@@ -960,8 +1060,8 @@ async function discSthClick(item) {
960
1060
 
961
1061
  popupLoading("Loading " + item);
962
1062
  try {
963
- const disc = await loadDiscImage(parsedQuery.disc1);
964
- processor.fdc.loadDisc(0, disc);
1063
+ const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
1064
+ putDiscIn(0, loaded);
965
1065
  loadingFinished();
966
1066
 
967
1067
  if (needsAutoboot) {
@@ -969,7 +1069,7 @@ async function discSthClick(item) {
969
1069
  }
970
1070
  } catch (err) {
971
1071
  console.error("Error loading disc image:", err);
972
- loadingFinished(err);
1072
+ loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
973
1073
  }
974
1074
  }
975
1075
 
@@ -984,7 +1084,7 @@ async function tapeSthClick(item) {
984
1084
  loadingFinished();
985
1085
  } catch (err) {
986
1086
  console.error("Error loading tape image:", err);
987
- loadingFinished(err);
1087
+ loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
988
1088
  }
989
1089
  }
990
1090
 
@@ -995,7 +1095,7 @@ document.getElementById("sth").addEventListener("shown.bs.modal", () => {
995
1095
 
996
1096
  function makeOnCat(onClick) {
997
1097
  return function (cat) {
998
- sthClearList();
1098
+ clearArchiveList("sth-list");
999
1099
  const sthList = document.getElementById("sth-list");
1000
1100
  document.querySelector("#sth .loading").style.display = "none";
1001
1101
  const template = sthList.querySelector(".template");
@@ -1025,24 +1125,30 @@ function makeOnCat(onClick) {
1025
1125
  }
1026
1126
 
1027
1127
  function sthOnError() {
1028
- const sthLoading = document.querySelector("#sth .loading");
1029
- sthLoading.textContent = "There was an error accessing the STH archive";
1030
- sthLoading.style.display = "";
1031
- sthClearList();
1128
+ showArchiveMessage("sth", "sth-list", "There was an error accessing the STH archive");
1032
1129
  }
1033
1130
 
1034
1131
  discSth = new StairwayToHell(sthStartLoad, makeOnCat(discSthClick), sthOnError, false);
1035
1132
  tapeSth = new StairwayToHell(sthStartLoad, makeOnCat(tapeSthClick), sthOnError, true);
1036
-
1037
- const sthAutoboot = document.querySelector("#sth .autoboot");
1038
- sthAutoboot.addEventListener("click", function () {
1039
- if (sthAutoboot.checked) {
1040
- parsedQuery.autoboot = "";
1041
- } else {
1042
- delete parsedQuery.autoboot;
1043
- }
1044
- updateUrl();
1045
- });
1133
+ hfeArchive = new BbcDiscArchive(hfeStartLoad, hfeOnCat, hfeOnError);
1134
+
1135
+ // Every archive picker offers the same autoboot choice, and it is one setting,
1136
+ // so ticking it in either has to show in both.
1137
+ const autobootChecks = document.querySelectorAll("#sth .autoboot, #hfe .autoboot");
1138
+ function showAutoboot(checked) {
1139
+ for (const check of autobootChecks) check.checked = checked;
1140
+ }
1141
+ for (const check of autobootChecks) {
1142
+ check.addEventListener("click", function () {
1143
+ showAutoboot(check.checked);
1144
+ if (check.checked) {
1145
+ parsedQuery.autoboot = "";
1146
+ } else {
1147
+ delete parsedQuery.autoboot;
1148
+ }
1149
+ updateUrl();
1150
+ });
1151
+ }
1046
1152
 
1047
1153
  document.addEventListener("click", function (e) {
1048
1154
  const target = e.target.closest("a.sth");
@@ -1058,16 +1164,94 @@ document.addEventListener("click", function (e) {
1058
1164
  });
1059
1165
 
1060
1166
  function setSthFilter(filter) {
1061
- filter = filter.toLowerCase();
1062
- for (const el of document.querySelectorAll("#sth-list li:not(.template)")) {
1063
- el.style.display = el.textContent.toLowerCase().indexOf(filter) >= 0 ? "" : "none";
1064
- }
1167
+ filterArchiveList("sth-list", filter);
1065
1168
  }
1066
1169
 
1067
1170
  const sthFilter = document.getElementById("sth-filter");
1068
1171
  sthFilter.addEventListener("change", () => setSthFilter(sthFilter.value));
1069
1172
  sthFilter.addEventListener("keyup", () => setSthFilter(sthFilter.value));
1070
1173
 
1174
+ // Rendering is spread over several turns of the event loop, so a list that has
1175
+ // been emptied may still have a chain of appends heading for it. Anything that
1176
+ // clears the list takes a new ticket; a chain whose ticket is stale gives up.
1177
+ let hfeRender = 0;
1178
+
1179
+ function hfeStartLoad() {
1180
+ hfeRender++;
1181
+ showArchiveMessage("hfe", "hfe-list", "Loading catalogue from HFE archive");
1182
+ }
1183
+
1184
+ function hfeOnError() {
1185
+ hfeRender++;
1186
+ showArchiveMessage("hfe", "hfe-list", "There was an error accessing the HFE archive");
1187
+ }
1188
+
1189
+ async function hfeClick(file) {
1190
+ utils.noteEvent("hfe", "click", file.path);
1191
+ setDisc1Image("hfe:" + file.path);
1192
+ const needsAutoboot = parsedQuery.autoboot !== undefined;
1193
+ if (needsAutoboot) processor.reset(true);
1194
+
1195
+ const name = describeHfe(file).title;
1196
+ popupLoading("Loading " + name);
1197
+ try {
1198
+ const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
1199
+ putDiscIn(0, loaded);
1200
+ loadingFinished();
1201
+ if (needsAutoboot) autoboot(name);
1202
+ } catch (err) {
1203
+ console.error("Error loading disc image:", err);
1204
+ loadingFinished(`Unable to load ${name} from the HFE archive: ${errorText(err)}`);
1205
+ }
1206
+ }
1207
+
1208
+ function hfeOnCat(catalogue) {
1209
+ const ticket = ++hfeRender;
1210
+ clearArchiveList("hfe-list");
1211
+ const list = document.getElementById("hfe-list");
1212
+ document.querySelector("#hfe .loading").style.display = "none";
1213
+ const template = list.querySelector(".template");
1214
+
1215
+ const addSome = (remaining) => {
1216
+ if (ticket !== hfeRender) return;
1217
+ const MaxAtATime = 100;
1218
+ const Delay = 30;
1219
+ // Read per batch: the filter can be typed into while this is still going.
1220
+ const filter = document.getElementById("hfe-filter").value.toLowerCase();
1221
+ for (const file of remaining.slice(0, MaxAtATime)) {
1222
+ const { title, publisher, detail } = describeHfe(file);
1223
+ const row = template.cloneNode(true);
1224
+ row.classList.remove("template");
1225
+ row.querySelector(".name").textContent = title;
1226
+ row.querySelector(".publisher").textContent = publisher;
1227
+ row.querySelector(".detail").textContent = detail;
1228
+ if (file.notes) row.title = file.notes;
1229
+ // The row is an anchor, and letting it navigate to "#" would push a
1230
+ // history entry of its own on top of the one updateUrl pushes.
1231
+ row.addEventListener("click", (event) => {
1232
+ event.preventDefault();
1233
+ hfeClick(file);
1234
+ $hfeModal.hide();
1235
+ });
1236
+ row.style.display = row.textContent.toLowerCase().includes(filter) ? "" : "none";
1237
+ list.appendChild(row);
1238
+ }
1239
+ if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
1240
+ };
1241
+ addSome(catalogue);
1242
+ }
1243
+
1244
+ const $hfeModal = new bootstrap.Modal(document.getElementById("hfe"));
1245
+ document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
1246
+ document.getElementById("hfe-filter").focus();
1247
+ });
1248
+ document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
1249
+
1250
+ const hfeFilter = document.getElementById("hfe-filter");
1251
+ const onHfeFilter = () => filterArchiveList("hfe-list", hfeFilter.value);
1252
+ hfeFilter.addEventListener("change", onHfeFilter);
1253
+ hfeFilter.addEventListener("keyup", onHfeFilter);
1254
+
1071
1255
  function sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
1072
1256
  if (keyboard) {
1073
1257
  keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
@@ -1138,10 +1322,13 @@ async function reloadSnapshotMedia(media) {
1138
1322
  const imageDataKey = discKey + "ImageData";
1139
1323
  const crcKey = discKey + "Crc32";
1140
1324
 
1325
+ // A snapshot from before layout detection has no field, and was contiguous.
1326
+ const layout = media[discKey + "Layout"] ?? DiscLayout.contiguous;
1327
+
1141
1328
  let loadedDisc = null;
1142
1329
  if (media[discKey]) {
1143
1330
  // URL-based disc — reload from source
1144
- loadedDisc = await loadDiscImage(media[discKey]);
1331
+ loadedDisc = await loadDiscImage(media[discKey], layout);
1145
1332
  } else if (media[imageDataKey]) {
1146
1333
  // Locally-loaded disc — reconstruct from embedded image data
1147
1334
  const imageData =
@@ -1149,7 +1336,7 @@ async function reloadSnapshotMedia(media) {
1149
1336
  ? media[imageDataKey]
1150
1337
  : new Uint8Array(Object.values(media[imageDataKey]));
1151
1338
  const discName = media[discKey + "Name"] || "snapshot.ssd";
1152
- loadedDisc = disc.discFor(processor.fdc, discName, imageData);
1339
+ loadedDisc = disc.discFor(processor.fdc, discName, imageData, undefined, layout);
1153
1340
  // Retain the image bytes so subsequent saves can re-embed them.
1154
1341
  loadedDisc.setOriginalImage(imageData);
1155
1342
  }
@@ -1158,14 +1345,14 @@ async function reloadSnapshotMedia(media) {
1158
1345
  // Verify CRC32 if present
1159
1346
  if (media[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
1160
1347
  if (loadedDisc.originalImageCrc32 !== media[crcKey]) {
1161
- showError(
1162
- "loading state",
1163
- "The disc image appears to have changed since this snapshot was saved. The restored state may not work correctly.",
1348
+ toast(
1349
+ `${loadedDisc.name} has changed since this state was saved. The state has been restored anyway and may not run correctly.`,
1350
+ { title: "Restoring state" },
1164
1351
  );
1165
1352
  }
1166
1353
  }
1167
1354
 
1168
- processor.fdc.loadDisc(driveIndex, loadedDisc);
1355
+ putDiscIn(driveIndex, loadedDisc);
1169
1356
  // Only update the URL/query for URL-sourced discs. For embedded
1170
1357
  // (local-file) discs, setting parsedQuery would put a bogus source
1171
1358
  // in the URL and break subsequent saves/reloads.
@@ -1176,13 +1363,18 @@ async function reloadSnapshotMedia(media) {
1176
1363
  }
1177
1364
  }
1178
1365
 
1179
- async function loadDiscImage(discImage) {
1366
+ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1180
1367
  if (!discImage) return null;
1181
1368
  const split = splitImage(discImage);
1182
1369
  discImage = split.image;
1183
1370
  const schema = split.schema;
1184
1371
  if (schema[0] === "!" || schema === "local") {
1185
- return disc.localDisc(processor.fdc, discImage);
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
+ );
1186
1378
  }
1187
1379
  // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
1188
1380
  // Consider:
@@ -1192,8 +1384,13 @@ async function loadDiscImage(discImage) {
1192
1384
  // * Dialog box (ugh) saying "is this ok?"
1193
1385
  switch (schema) {
1194
1386
  case "|":
1195
- case "sth":
1196
- return disc.discFor(processor.fdc, discImage, await discSth.fetch(discImage));
1387
+ case "sth": {
1388
+ const { name, data } = await discSth.fetch(discImage);
1389
+ return disc.discFor(processor.fdc, name, data, undefined, layout);
1390
+ }
1391
+
1392
+ case "hfe":
1393
+ return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage), undefined, layout);
1197
1394
 
1198
1395
  case "gd": {
1199
1396
  const splat = discImage.match(/([^/]+)\/?(.*)/);
@@ -1202,15 +1399,15 @@ async function loadDiscImage(discImage) {
1202
1399
  discImage = splat[1];
1203
1400
  name = splat[2];
1204
1401
  }
1205
- return gdLoad({ name, id: discImage });
1402
+ return gdLoad({ name, id: discImage }, layout);
1206
1403
  }
1207
1404
  case "b64data":
1208
- return disc.discFor(processor.fdc, "disk.ssd", atob(discImage));
1405
+ return disc.discFor(processor.fdc, "disk.ssd", atob(discImage), undefined, layout);
1209
1406
 
1210
1407
  case "data": {
1211
1408
  const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
1212
1409
  const { name, data } = await utils.unzipDiscImage(arr);
1213
- return disc.discFor(processor.fdc, name, data);
1410
+ return disc.discFor(processor.fdc, name, data, undefined, layout);
1214
1411
  }
1215
1412
  case "http":
1216
1413
  case "https":
@@ -1224,10 +1421,10 @@ async function loadDiscImage(discImage) {
1224
1421
  discData = unzipped.data;
1225
1422
  discImage = unzipped.name;
1226
1423
  }
1227
- return disc.discFor(processor.fdc, discImage, discData);
1424
+ return disc.discFor(processor.fdc, discImage, discData, undefined, layout);
1228
1425
  }
1229
1426
  default:
1230
- return disc.discFor(processor.fdc, discImage, await disc.load("discs/" + discImage));
1427
+ return disc.discFor(processor.fdc, discImage, await disc.load("discs/" + discImage), undefined, layout);
1231
1428
  }
1232
1429
  }
1233
1430
 
@@ -1238,8 +1435,10 @@ async function loadTapeImage(tapeImage) {
1238
1435
 
1239
1436
  switch (schema) {
1240
1437
  case "|":
1241
- case "sth":
1242
- return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), model);
1438
+ case "sth": {
1439
+ const { name, data } = await tapeSth.fetch(tapeImage);
1440
+ return await loadTapeFromData(name, data, model);
1441
+ }
1243
1442
 
1244
1443
  case "data": {
1245
1444
  const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
@@ -1337,17 +1536,10 @@ function popupLoading(msg) {
1337
1536
  loadingDialogModal.show();
1338
1537
  }
1339
1538
 
1340
- function loadingFinished(error) {
1539
+ function loadingFinished(message) {
1341
1540
  googleDriveAuth.style.display = "none";
1342
- if (error) {
1343
- loadingDialogModal.show();
1344
- loadingDialog.querySelector(".loading").textContent = "Error: " + error;
1345
- setTimeout(function () {
1346
- loadingDialogModal.hide();
1347
- }, 5000);
1348
- } else {
1349
- loadingDialogModal.hide();
1350
- }
1541
+ loadingDialogModal.hide();
1542
+ if (message) toast(message);
1351
1543
  }
1352
1544
 
1353
1545
  const googleDrive = new GoogleDriveLoader();
@@ -1372,7 +1564,7 @@ document.querySelector("#google-drive-auth form").addEventListener("submit", asy
1372
1564
  else googleDriveLoadingReject(new Error("Unable to authorize Google Drive"));
1373
1565
  });
1374
1566
 
1375
- async function gdLoad(cat) {
1567
+ async function gdLoad(cat, layout) {
1376
1568
  // TODO: have a onclose flush event, handle errors
1377
1569
  /*
1378
1570
  $(window).bind("beforeunload", function() {
@@ -1396,22 +1588,26 @@ async function gdLoad(cat) {
1396
1588
  });
1397
1589
  }
1398
1590
 
1399
- const ssd = await googleDrive.load(processor.fdc, cat.id);
1591
+ const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
1400
1592
  console.log("Google Drive loading finished");
1401
1593
  loadingFinished();
1402
1594
  return ssd;
1403
1595
  } catch (error) {
1404
1596
  console.error("Google Drive loading error:", error);
1405
- loadingFinished(error);
1597
+ loadingFinished(`Unable to load ${cat.name} from Google Drive: ${errorText(error)}`);
1406
1598
  }
1407
1599
  }
1408
1600
 
1409
1601
  for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "none";
1410
1602
  (async () => {
1411
- const available = await googleDrive.initialise();
1412
- if (available) {
1413
- for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
1414
- 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)}`);
1415
1611
  }
1416
1612
  })();
1417
1613
  const googleDriveModal = new bootstrap.Modal(googleDriveEl);
@@ -1440,8 +1636,8 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
1440
1636
  utils.noteEvent("google-drive", "click", item.name);
1441
1637
  setDisc1Image(`gd:${item.id}/${item.name}`);
1442
1638
  googleDriveModal.hide();
1443
- const ssd = await gdLoad(item);
1444
- if (ssd) processor.fdc.loadDisc(0, ssd);
1639
+ const ssd = await gdLoad(item, layoutForDrive(0));
1640
+ if (ssd) putDiscIn(0, ssd);
1445
1641
  });
1446
1642
  }
1447
1643
  });
@@ -1457,7 +1653,7 @@ for (const image of availableImages) {
1457
1653
  utils.noteEvent("images", "click", image.file);
1458
1654
  setDisc1Image(image.file);
1459
1655
  $discsModal.hide();
1460
- processor.fdc.loadDisc(0, await loadDiscImage(parsedQuery.disc1));
1656
+ putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1461
1657
  });
1462
1658
  }
1463
1659
 
@@ -1476,7 +1672,7 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1476
1672
  try {
1477
1673
  data = discType.saver(processor.fdc.drives[0].disc);
1478
1674
  } catch (e) {
1479
- loadingFinished(`Create failed: ${e.message}`);
1675
+ loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(e)}`);
1480
1676
  return;
1481
1677
  }
1482
1678
  name = replaceOrAddExtension(name, discType.extension);
@@ -1497,11 +1693,11 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1497
1693
  try {
1498
1694
  const result = await googleDrive.create(processor.fdc, name, data);
1499
1695
  setDisc1Image("gd:" + result.fileId + "/" + name);
1500
- processor.fdc.loadDisc(0, result.disc);
1696
+ putDiscIn(0, result.disc);
1501
1697
  loadingFinished();
1502
1698
  } catch (error) {
1503
1699
  console.error(`Error creating Google Drive disc: ${error}`, error);
1504
- loadingFinished(`Create failed: ${error}`);
1700
+ loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(error)}`);
1505
1701
  }
1506
1702
  });
1507
1703
 
@@ -1567,6 +1763,9 @@ document.getElementById("save-state").addEventListener("click", async function (
1567
1763
  const discKey = driveIndex === 0 ? "disc1" : "disc2";
1568
1764
  const crcKey = discKey + "Crc32";
1569
1765
  media[crcKey] = driveDisc.originalImageCrc32;
1766
+ // The snapshot's dirty tracks are indexed by physical track, so restoring has to lay
1767
+ // the disc out the way this one was rather than work it out again.
1768
+ media[discKey + "Layout"] = driveDisc.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
1570
1769
  if (!media[discKey] && driveDisc.originalImageData) {
1571
1770
  media[discKey + "ImageData"] = driveDisc.originalImageData;
1572
1771
  media[discKey + "Name"] = driveDisc.name;
@@ -1742,90 +1941,85 @@ const startPromise = (async () => {
1742
1941
  // Ideally would start the loads first. But their completion needs the FDC from the processor
1743
1942
  const imageLoads = [];
1744
1943
 
1944
+ function startImageLoad(description, load) {
1945
+ const loading = (async () => {
1946
+ try {
1947
+ await load();
1948
+ } catch (error) {
1949
+ console.error(`Error loading ${description}:`, error);
1950
+ toast(`Could not load ${description}: ${error?.message ?? error}`, { title: "Loading" });
1951
+ }
1952
+ })();
1953
+ imageLoads.push(loading);
1954
+ return loading;
1955
+ }
1956
+
1745
1957
  if (discImage) {
1746
- imageLoads.push(
1747
- (async () => {
1748
- const disc = await loadDiscImage(discImage);
1749
- processor.fdc.loadDisc(0, disc);
1750
- })(),
1958
+ startImageLoad(`disc ${discImage}`, async () =>
1959
+ putDiscIn(0, await loadDiscImage(discImage, layoutForDrive(0))),
1751
1960
  );
1752
1961
  }
1753
1962
 
1754
1963
  if (secondDiscImage) {
1755
- imageLoads.push(
1756
- (async () => {
1757
- const disc = await loadDiscImage(secondDiscImage);
1758
- processor.fdc.loadDisc(1, disc);
1759
- })(),
1964
+ startImageLoad(`disc ${secondDiscImage}`, async () =>
1965
+ putDiscIn(1, await loadDiscImage(secondDiscImage, layoutForDrive(1))),
1760
1966
  );
1761
1967
  }
1762
1968
 
1763
1969
  if (parsedQuery.tape) {
1764
- imageLoads.push(
1765
- (async () => {
1766
- const tape = await loadTapeImage(parsedQuery.tape);
1767
- setProcessorTape(tape);
1768
- })(),
1769
- );
1970
+ startImageLoad(`tape ${parsedQuery.tape}`, async () => setProcessorTape(await loadTapeImage(parsedQuery.tape)));
1770
1971
  }
1771
1972
 
1772
1973
  if (mmcImage && model.isAtom) {
1773
- imageLoads.push(
1774
- (async () => {
1775
- const files = await LoadSD(mmcImage);
1776
- processor.atommc.SetMMCData(files);
1777
- })(),
1778
- );
1974
+ startImageLoad(`MMC image ${mmcImage}`, async () => processor.atommc.SetMMCData(await LoadSD(mmcImage)));
1779
1975
  }
1780
1976
 
1781
1977
  async function insertBasic(getBasicPromise, needsRun) {
1782
- const basicLoadPromise = (async () => {
1783
- const prog = await getBasicPromise;
1784
- const t = await tokeniser.create();
1785
- const tokenised = await t.tokenise(prog);
1786
-
1787
- const idleAddr = processor.model.isMaster ? 0xe7e6 : 0xe581;
1788
- const hook = processor.debugInstruction.add(function (addr) {
1789
- if (addr !== idleAddr) return;
1790
- const page = processor.readmem(0x18) << 8;
1791
- for (let i = 0; i < tokenised.length; ++i) {
1792
- processor.writemem(page + i, tokenised.charCodeAt(i));
1793
- }
1794
- // Set VARTOP (0x12/3) and TOP(0x02/3)
1795
- const end = page + tokenised.length;
1796
- const endLow = end & 0xff;
1797
- const endHigh = (end >>> 8) & 0xff;
1798
- processor.writemem(0x02, endLow);
1799
- processor.writemem(0x03, endHigh);
1800
- processor.writemem(0x12, endLow);
1801
- processor.writemem(0x13, endHigh);
1802
- hook.remove();
1803
- if (needsRun) {
1804
- autoRunBasic();
1805
- }
1806
- });
1807
- return tokenised; // Explicitly return the result
1808
- })();
1809
-
1810
- imageLoads.push(basicLoadPromise);
1811
- return basicLoadPromise; // Return promise for caller to await if needed
1978
+ const prog = await getBasicPromise;
1979
+ const t = await tokeniser.create();
1980
+ const tokenised = await t.tokenise(prog);
1981
+
1982
+ const idleAddr = processor.model.isMaster ? 0xe7e6 : 0xe581;
1983
+ const hook = processor.debugInstruction.add(function (addr) {
1984
+ if (addr !== idleAddr) return;
1985
+ const page = processor.readmem(0x18) << 8;
1986
+ for (let i = 0; i < tokenised.length; ++i) {
1987
+ processor.writemem(page + i, tokenised.charCodeAt(i));
1988
+ }
1989
+ // Set VARTOP (0x12/3) and TOP(0x02/3)
1990
+ const end = page + tokenised.length;
1991
+ const endLow = end & 0xff;
1992
+ const endHigh = (end >>> 8) & 0xff;
1993
+ processor.writemem(0x02, endLow);
1994
+ processor.writemem(0x03, endHigh);
1995
+ processor.writemem(0x12, endLow);
1996
+ processor.writemem(0x13, endHigh);
1997
+ hook.remove();
1998
+ if (needsRun) {
1999
+ autoRunBasic();
2000
+ }
2001
+ });
1812
2002
  }
1813
2003
 
1814
2004
  if (parsedQuery.loadBasic) {
1815
2005
  const needsRun = needsAutoboot === "run";
1816
2006
  needsAutoboot = "";
1817
2007
 
1818
- await insertBasic(
1819
- (async () => {
1820
- const data = await utils.loadData(parsedQuery.loadBasic);
1821
- return String.fromCharCode.apply(null, data);
1822
- })(),
1823
- needsRun,
2008
+ await startImageLoad(`BASIC program ${parsedQuery.loadBasic}`, () =>
2009
+ insertBasic(
2010
+ (async () => {
2011
+ const data = await utils.loadData(parsedQuery.loadBasic);
2012
+ return String.fromCharCode.apply(null, data);
2013
+ })(),
2014
+ needsRun,
2015
+ ),
1824
2016
  );
1825
2017
  }
1826
2018
 
1827
2019
  if (parsedQuery.embedBasic) {
1828
- await insertBasic(Promise.resolve(parsedQuery.embedBasic), true);
2020
+ await startImageLoad("the BASIC program from the URL", () =>
2021
+ insertBasic(Promise.resolve(parsedQuery.embedBasic), true),
2022
+ );
1829
2023
  }
1830
2024
 
1831
2025
  return Promise.all(imageLoads);
@@ -1837,7 +2031,7 @@ const startPromise = (async () => {
1837
2031
 
1838
2032
  switch (needsAutoboot) {
1839
2033
  case "boot":
1840
- sthAutoboot.checked = true;
2034
+ showAutoboot(true);
1841
2035
  autoboot(discImage);
1842
2036
  break;
1843
2037
  case "type":
@@ -1850,7 +2044,7 @@ const startPromise = (async () => {
1850
2044
  autoRunTape();
1851
2045
  break;
1852
2046
  default:
1853
- sthAutoboot.checked = false;
2047
+ showAutoboot(false);
1854
2048
  break;
1855
2049
  }
1856
2050
 
@@ -1998,6 +2192,23 @@ rewindUI.updateButtonState();
1998
2192
  if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
1999
2193
  else document.getElementById("disc-visualiser-open").classList.add("disabled");
2000
2194
 
2195
+ for (const item of document.querySelectorAll(".drive-tracks")) {
2196
+ const driveIndex = Number(item.dataset.drive);
2197
+ const drive = processor.fdc?.drives[driveIndex];
2198
+ const fixed = drive ? tracksPerStepForDrive(driveIndex) : undefined;
2199
+ if (fixed !== undefined) drive.tracksPerStep = fixed;
2200
+ for (const button of driveTracksButtons(driveIndex)) {
2201
+ button.disabled = !drive;
2202
+ button.addEventListener("click", (event) => {
2203
+ // Setting a switch is not picking from a menu, so leave the menu where it is.
2204
+ event.stopPropagation();
2205
+ drive.tracksPerStep = tracksPerStepFor(button.dataset.tracks);
2206
+ showDriveTracks(driveIndex);
2207
+ });
2208
+ }
2209
+ if (drive) showDriveTracks(driveIndex);
2210
+ }
2211
+
2001
2212
  function draw(now) {
2002
2213
  if (!running) {
2003
2214
  last = 0;