jsbeeb 1.16.0 → 1.17.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
@@ -12,6 +12,7 @@ import * as utils_atom from "./utils_atom.js";
12
12
  import { LoadSD } from "./mmc.js";
13
13
  import { Cmos } 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,14 +20,15 @@ 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";
32
34
  import { MouseJoystickSource } from "./mouse-joystick-source.js";
@@ -48,11 +50,13 @@ import { DiscVisualiser } from "./disc-visualiser.js";
48
50
  import { downloadBlob } from "./dom-utils.js";
49
51
  import {
50
52
  buildUrlFromParams,
53
+ DriveTracks,
51
54
  guessModelFromHostname,
52
55
  ParamTypes,
53
56
  parseMediaParams,
54
57
  parseQueryString,
55
58
  processAutobootParams,
59
+ processDriveTrackParams,
56
60
  processInputParams,
57
61
  } from "./url-params.js";
58
62
 
@@ -65,6 +69,7 @@ let frameSkip = 0;
65
69
  let syncLights;
66
70
  let discSth;
67
71
  let tapeSth;
72
+ let hfeArchive;
68
73
  let running;
69
74
  let model;
70
75
 
@@ -153,6 +158,8 @@ const paramTypes = {
153
158
  keyLayout: ParamTypes.STRING,
154
159
  autotype: ParamTypes.STRING,
155
160
  displayMode: ParamTypes.STRING,
161
+ drive0Tracks: ParamTypes.STRING,
162
+ drive1Tracks: ParamTypes.STRING,
156
163
  };
157
164
 
158
165
  // Parse the query string with parameter types
@@ -173,6 +180,7 @@ let econet = null;
173
180
 
174
181
  // Parse disc and tape images from query parameters
175
182
  const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
183
+ const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
176
184
 
177
185
  // Only assign if values are provided
178
186
  if (queryDiscImage) discImage = queryDiscImage;
@@ -282,7 +290,13 @@ const config = new Config(
282
290
  // Perform mapping of legacy models to the new format
283
291
  config.mapLegacyModels(parsedQuery);
284
292
 
285
- config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
293
+ const requestedModelName = parsedQuery.model || guessModelFromHostname(window.location.hostname);
294
+ const requestedModel = findModel(requestedModelName);
295
+ if (!requestedModel)
296
+ toast(`There is no model called "${requestedModelName}". Using ${DefaultModel.name} instead.`, {
297
+ title: "Model",
298
+ });
299
+ config.setModel((requestedModel ?? DefaultModel).name);
286
300
  config.setKeyLayout(keyLayout);
287
301
  config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
288
302
  config.setMicrophoneChannel(parsedQuery.microphoneChannel);
@@ -383,8 +397,65 @@ function showError(context, error) {
383
397
  errorDialogModal.show();
384
398
  }
385
399
 
400
+ const errorText = (error) => error?.message ?? `${error}`;
401
+
402
+ function showNotice(event) {
403
+ const { message, title, quietKey } = event.detail;
404
+ toast(message, { title, quietKey });
405
+ }
406
+
386
407
  if (keyMappingWarnings.length) {
387
- showError("applying the key mappings in the URL", keyMappingWarnings.join(" "));
408
+ toast(`${keyMappingWarnings.join(" ")} The key names are listed in the README.`, {
409
+ title: "Mappings in the URL",
410
+ });
411
+ }
412
+
413
+ if (driveTrackWarnings.length) {
414
+ toast(`${driveTrackWarnings.join(" ")} Auto is in use instead; pick 40 or 80 from the Discs menu.`, {
415
+ title: "Disc drives",
416
+ });
417
+ }
418
+
419
+ /** @returns {string} the DiscLayout to load an image for this drive with */
420
+ function layoutForDrive(driveIndex) {
421
+ return driveTracks[driveIndex] === DriveTracks.eighty ? DiscLayout.contiguous : DiscLayout.auto;
422
+ }
423
+
424
+ /** @returns {Number|undefined} the tracksPerStep the user fixed this drive at, if they fixed one */
425
+ function tracksPerStepForDrive(driveIndex) {
426
+ if (driveTracks[driveIndex] === DriveTracks.auto) return undefined;
427
+ return driveTracks[driveIndex] === DriveTracks.forty ? 2 : 1;
428
+ }
429
+
430
+ function putDiscIn(driveIndex, loadedDisc) {
431
+ const drive = processor.fdc.drives[driveIndex];
432
+ const fixed = tracksPerStepForDrive(driveIndex);
433
+ const was = drive.tracksPerStep;
434
+ processor.fdc.loadDisc(driveIndex, loadedDisc, fixed);
435
+ showDriveTracks(driveIndex);
436
+ // A switch the user fixed does not move, so anything it does is not news.
437
+ if (fixed === undefined && drive.tracksPerStep !== was) noteDriveTracks(driveIndex, loadedDisc.name);
438
+ }
439
+
440
+ const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
441
+
442
+ function showDriveTracks(driveIndex) {
443
+ const drive = processor.fdc?.drives[driveIndex];
444
+ if (!drive) return;
445
+ for (const button of driveTracksButtons(driveIndex))
446
+ button.classList.toggle("active", tracksPerStepFor(button.dataset.tracks) === drive.tracksPerStep);
447
+ }
448
+
449
+ function driveTracksButtons(driveIndex) {
450
+ return document.querySelectorAll(`.drive-tracks[data-drive="${driveIndex}"] [data-tracks]`);
451
+ }
452
+
453
+ function noteDriveTracks(driveIndex, discName) {
454
+ const tracks = processor.fdc.drives[driveIndex].tracksPerStep === 2 ? "40" : "80";
455
+ toast(`Drive ${driveIndex} switched to ${tracks} track for ${discName}.`, {
456
+ title: "Disc drive",
457
+ quietKey: "quietDriveTracks",
458
+ });
388
459
  }
389
460
 
390
461
  function createCanvasForFilter(filterClass) {
@@ -402,10 +473,11 @@ function createCanvasForFilter(filterClass) {
402
473
  // filter can decline a context that works perfectly well for other modes,
403
474
  // in which case bestCanvas quietly gives us an unfiltered GL canvas.
404
475
  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
- );
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
+ });
409
481
  }
410
482
 
411
483
  return newCanvas;
@@ -516,10 +588,10 @@ function downloadDriveData(data, name, extension) {
516
588
 
517
589
  async function loadHTMLFile(file) {
518
590
  const imageData = utils.stringToUint8Array(await readFileAsBinaryString(file));
519
- const loadedDisc = disc.discFor(processor.fdc, file.name, imageData);
591
+ const loadedDisc = disc.discFor(processor.fdc, file.name, imageData, undefined, layoutForDrive(0));
520
592
  // Local file: retain the image bytes for embedding in save-to-file snapshots.
521
593
  loadedDisc.setOriginalImage(imageData);
522
- processor.fdc.loadDisc(0, loadedDisc);
594
+ putDiscIn(0, loadedDisc);
523
595
  delete parsedQuery.disc;
524
596
  delete parsedQuery.disc1;
525
597
  updateUrl();
@@ -696,14 +768,14 @@ processor = new CpuClass(model, {
696
768
  econet,
697
769
  });
698
770
 
699
- processor.teletextAdaptor?.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
771
+ processor.teletextAdaptor?.addEventListener("notice", showNotice);
700
772
 
701
773
  // Create input sources
702
774
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
703
775
  // Create MicrophoneInput but don't enable by default
704
776
  const microphoneInput = new MicrophoneInput();
705
777
  microphoneInput.setErrorCallback((message) => {
706
- showError("accessing microphone", message);
778
+ toast(`${message} The microphone channel has been turned off.`, { title: "Microphone" });
707
779
  });
708
780
 
709
781
  // Create MouseJoystickSource but don't enable by default
@@ -804,7 +876,7 @@ keyboard = new Keyboard({
804
876
  keyLayout,
805
877
  dbgr,
806
878
  });
807
- keyboard.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
879
+ keyboard.addEventListener("notice", showNotice);
808
880
  keyboard.addEventListener("pause", () => stop(false));
809
881
  keyboard.addEventListener("resume", () => go());
810
882
  keyboard.addEventListener("break", (e) => {
@@ -939,15 +1011,26 @@ function setTapeImage(name) {
939
1011
  config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
940
1012
  }
941
1013
 
942
- function sthClearList() {
943
- for (const el of document.querySelectorAll("#sth-list li:not(.template)")) el.remove();
1014
+ function clearArchiveList(listId) {
1015
+ for (const el of document.querySelectorAll(`#${listId} li:not(.template)`)) el.remove();
1016
+ }
1017
+
1018
+ function showArchiveMessage(modalId, listId, message) {
1019
+ const loading = document.querySelector(`#${modalId} .loading`);
1020
+ loading.textContent = message;
1021
+ loading.style.display = "";
1022
+ clearArchiveList(listId);
1023
+ }
1024
+
1025
+ function filterArchiveList(listId, filter) {
1026
+ filter = filter.toLowerCase();
1027
+ for (const el of document.querySelectorAll(`#${listId} li:not(.template)`)) {
1028
+ el.style.display = el.textContent.toLowerCase().includes(filter) ? "" : "none";
1029
+ }
944
1030
  }
945
1031
 
946
1032
  function sthStartLoad() {
947
- const sthLoading = document.querySelector("#sth .loading");
948
- sthLoading.textContent = "Loading catalog from STH archive";
949
- sthLoading.style.display = "";
950
- sthClearList();
1033
+ showArchiveMessage("sth", "sth-list", "Loading catalog from STH archive");
951
1034
  }
952
1035
 
953
1036
  async function discSthClick(item) {
@@ -960,8 +1043,8 @@ async function discSthClick(item) {
960
1043
 
961
1044
  popupLoading("Loading " + item);
962
1045
  try {
963
- const disc = await loadDiscImage(parsedQuery.disc1);
964
- processor.fdc.loadDisc(0, disc);
1046
+ const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
1047
+ putDiscIn(0, loaded);
965
1048
  loadingFinished();
966
1049
 
967
1050
  if (needsAutoboot) {
@@ -969,7 +1052,7 @@ async function discSthClick(item) {
969
1052
  }
970
1053
  } catch (err) {
971
1054
  console.error("Error loading disc image:", err);
972
- loadingFinished(err);
1055
+ loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
973
1056
  }
974
1057
  }
975
1058
 
@@ -984,7 +1067,7 @@ async function tapeSthClick(item) {
984
1067
  loadingFinished();
985
1068
  } catch (err) {
986
1069
  console.error("Error loading tape image:", err);
987
- loadingFinished(err);
1070
+ loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
988
1071
  }
989
1072
  }
990
1073
 
@@ -995,7 +1078,7 @@ document.getElementById("sth").addEventListener("shown.bs.modal", () => {
995
1078
 
996
1079
  function makeOnCat(onClick) {
997
1080
  return function (cat) {
998
- sthClearList();
1081
+ clearArchiveList("sth-list");
999
1082
  const sthList = document.getElementById("sth-list");
1000
1083
  document.querySelector("#sth .loading").style.display = "none";
1001
1084
  const template = sthList.querySelector(".template");
@@ -1025,24 +1108,30 @@ function makeOnCat(onClick) {
1025
1108
  }
1026
1109
 
1027
1110
  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();
1111
+ showArchiveMessage("sth", "sth-list", "There was an error accessing the STH archive");
1032
1112
  }
1033
1113
 
1034
1114
  discSth = new StairwayToHell(sthStartLoad, makeOnCat(discSthClick), sthOnError, false);
1035
1115
  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
- });
1116
+ hfeArchive = new BbcDiscArchive(hfeStartLoad, hfeOnCat, hfeOnError);
1117
+
1118
+ // Every archive picker offers the same autoboot choice, and it is one setting,
1119
+ // so ticking it in either has to show in both.
1120
+ const autobootChecks = document.querySelectorAll("#sth .autoboot, #hfe .autoboot");
1121
+ function showAutoboot(checked) {
1122
+ for (const check of autobootChecks) check.checked = checked;
1123
+ }
1124
+ for (const check of autobootChecks) {
1125
+ check.addEventListener("click", function () {
1126
+ showAutoboot(check.checked);
1127
+ if (check.checked) {
1128
+ parsedQuery.autoboot = "";
1129
+ } else {
1130
+ delete parsedQuery.autoboot;
1131
+ }
1132
+ updateUrl();
1133
+ });
1134
+ }
1046
1135
 
1047
1136
  document.addEventListener("click", function (e) {
1048
1137
  const target = e.target.closest("a.sth");
@@ -1058,16 +1147,94 @@ document.addEventListener("click", function (e) {
1058
1147
  });
1059
1148
 
1060
1149
  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
- }
1150
+ filterArchiveList("sth-list", filter);
1065
1151
  }
1066
1152
 
1067
1153
  const sthFilter = document.getElementById("sth-filter");
1068
1154
  sthFilter.addEventListener("change", () => setSthFilter(sthFilter.value));
1069
1155
  sthFilter.addEventListener("keyup", () => setSthFilter(sthFilter.value));
1070
1156
 
1157
+ // Rendering is spread over several turns of the event loop, so a list that has
1158
+ // been emptied may still have a chain of appends heading for it. Anything that
1159
+ // clears the list takes a new ticket; a chain whose ticket is stale gives up.
1160
+ let hfeRender = 0;
1161
+
1162
+ function hfeStartLoad() {
1163
+ hfeRender++;
1164
+ showArchiveMessage("hfe", "hfe-list", "Loading catalogue from HFE archive");
1165
+ }
1166
+
1167
+ function hfeOnError() {
1168
+ hfeRender++;
1169
+ showArchiveMessage("hfe", "hfe-list", "There was an error accessing the HFE archive");
1170
+ }
1171
+
1172
+ async function hfeClick(file) {
1173
+ utils.noteEvent("hfe", "click", file.path);
1174
+ setDisc1Image("hfe:" + file.path);
1175
+ const needsAutoboot = parsedQuery.autoboot !== undefined;
1176
+ if (needsAutoboot) processor.reset(true);
1177
+
1178
+ const name = describeHfe(file).title;
1179
+ popupLoading("Loading " + name);
1180
+ try {
1181
+ const disc = await loadDiscImage(parsedQuery.disc1);
1182
+ processor.fdc.loadDisc(0, disc);
1183
+ loadingFinished();
1184
+ if (needsAutoboot) autoboot(name);
1185
+ } catch (err) {
1186
+ console.error("Error loading disc image:", err);
1187
+ loadingFinished(err);
1188
+ }
1189
+ }
1190
+
1191
+ function hfeOnCat(catalogue) {
1192
+ const ticket = ++hfeRender;
1193
+ clearArchiveList("hfe-list");
1194
+ const list = document.getElementById("hfe-list");
1195
+ document.querySelector("#hfe .loading").style.display = "none";
1196
+ const template = list.querySelector(".template");
1197
+
1198
+ const addSome = (remaining) => {
1199
+ if (ticket !== hfeRender) return;
1200
+ const MaxAtATime = 100;
1201
+ const Delay = 30;
1202
+ // Read per batch: the filter can be typed into while this is still going.
1203
+ const filter = document.getElementById("hfe-filter").value.toLowerCase();
1204
+ for (const file of remaining.slice(0, MaxAtATime)) {
1205
+ const { title, publisher, detail } = describeHfe(file);
1206
+ const row = template.cloneNode(true);
1207
+ row.classList.remove("template");
1208
+ row.querySelector(".name").textContent = title;
1209
+ row.querySelector(".publisher").textContent = publisher;
1210
+ row.querySelector(".detail").textContent = detail;
1211
+ if (file.notes) row.title = file.notes;
1212
+ // The row is an anchor, and letting it navigate to "#" would push a
1213
+ // history entry of its own on top of the one updateUrl pushes.
1214
+ row.addEventListener("click", (event) => {
1215
+ event.preventDefault();
1216
+ hfeClick(file);
1217
+ $hfeModal.hide();
1218
+ });
1219
+ row.style.display = row.textContent.toLowerCase().includes(filter) ? "" : "none";
1220
+ list.appendChild(row);
1221
+ }
1222
+ if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
1223
+ };
1224
+ addSome(catalogue);
1225
+ }
1226
+
1227
+ const $hfeModal = new bootstrap.Modal(document.getElementById("hfe"));
1228
+ document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
1229
+ document.getElementById("hfe-filter").focus();
1230
+ });
1231
+ document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
1232
+
1233
+ const hfeFilter = document.getElementById("hfe-filter");
1234
+ const onHfeFilter = () => filterArchiveList("hfe-list", hfeFilter.value);
1235
+ hfeFilter.addEventListener("change", onHfeFilter);
1236
+ hfeFilter.addEventListener("keyup", onHfeFilter);
1237
+
1071
1238
  function sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
1072
1239
  if (keyboard) {
1073
1240
  keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
@@ -1138,10 +1305,13 @@ async function reloadSnapshotMedia(media) {
1138
1305
  const imageDataKey = discKey + "ImageData";
1139
1306
  const crcKey = discKey + "Crc32";
1140
1307
 
1308
+ // A snapshot from before layout detection has no field, and was contiguous.
1309
+ const layout = media[discKey + "Layout"] ?? DiscLayout.contiguous;
1310
+
1141
1311
  let loadedDisc = null;
1142
1312
  if (media[discKey]) {
1143
1313
  // URL-based disc — reload from source
1144
- loadedDisc = await loadDiscImage(media[discKey]);
1314
+ loadedDisc = await loadDiscImage(media[discKey], layout);
1145
1315
  } else if (media[imageDataKey]) {
1146
1316
  // Locally-loaded disc — reconstruct from embedded image data
1147
1317
  const imageData =
@@ -1149,7 +1319,7 @@ async function reloadSnapshotMedia(media) {
1149
1319
  ? media[imageDataKey]
1150
1320
  : new Uint8Array(Object.values(media[imageDataKey]));
1151
1321
  const discName = media[discKey + "Name"] || "snapshot.ssd";
1152
- loadedDisc = disc.discFor(processor.fdc, discName, imageData);
1322
+ loadedDisc = disc.discFor(processor.fdc, discName, imageData, undefined, layout);
1153
1323
  // Retain the image bytes so subsequent saves can re-embed them.
1154
1324
  loadedDisc.setOriginalImage(imageData);
1155
1325
  }
@@ -1158,14 +1328,14 @@ async function reloadSnapshotMedia(media) {
1158
1328
  // Verify CRC32 if present
1159
1329
  if (media[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
1160
1330
  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.",
1331
+ toast(
1332
+ `${loadedDisc.name} has changed since this state was saved. The state has been restored anyway and may not run correctly.`,
1333
+ { title: "Restoring state" },
1164
1334
  );
1165
1335
  }
1166
1336
  }
1167
1337
 
1168
- processor.fdc.loadDisc(driveIndex, loadedDisc);
1338
+ putDiscIn(driveIndex, loadedDisc);
1169
1339
  // Only update the URL/query for URL-sourced discs. For embedded
1170
1340
  // (local-file) discs, setting parsedQuery would put a bogus source
1171
1341
  // in the URL and break subsequent saves/reloads.
@@ -1176,13 +1346,13 @@ async function reloadSnapshotMedia(media) {
1176
1346
  }
1177
1347
  }
1178
1348
 
1179
- async function loadDiscImage(discImage) {
1349
+ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1180
1350
  if (!discImage) return null;
1181
1351
  const split = splitImage(discImage);
1182
1352
  discImage = split.image;
1183
1353
  const schema = split.schema;
1184
1354
  if (schema[0] === "!" || schema === "local") {
1185
- return disc.localDisc(processor.fdc, discImage);
1355
+ return disc.localDisc(processor.fdc, discImage, layout);
1186
1356
  }
1187
1357
  // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
1188
1358
  // Consider:
@@ -1192,8 +1362,13 @@ async function loadDiscImage(discImage) {
1192
1362
  // * Dialog box (ugh) saying "is this ok?"
1193
1363
  switch (schema) {
1194
1364
  case "|":
1195
- case "sth":
1196
- return disc.discFor(processor.fdc, discImage, await discSth.fetch(discImage));
1365
+ case "sth": {
1366
+ const { name, data } = await discSth.fetch(discImage);
1367
+ return disc.discFor(processor.fdc, name, data, undefined, layout);
1368
+ }
1369
+
1370
+ case "hfe":
1371
+ return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage));
1197
1372
 
1198
1373
  case "gd": {
1199
1374
  const splat = discImage.match(/([^/]+)\/?(.*)/);
@@ -1202,15 +1377,15 @@ async function loadDiscImage(discImage) {
1202
1377
  discImage = splat[1];
1203
1378
  name = splat[2];
1204
1379
  }
1205
- return gdLoad({ name, id: discImage });
1380
+ return gdLoad({ name, id: discImage }, layout);
1206
1381
  }
1207
1382
  case "b64data":
1208
- return disc.discFor(processor.fdc, "disk.ssd", atob(discImage));
1383
+ return disc.discFor(processor.fdc, "disk.ssd", atob(discImage), undefined, layout);
1209
1384
 
1210
1385
  case "data": {
1211
1386
  const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
1212
1387
  const { name, data } = await utils.unzipDiscImage(arr);
1213
- return disc.discFor(processor.fdc, name, data);
1388
+ return disc.discFor(processor.fdc, name, data, undefined, layout);
1214
1389
  }
1215
1390
  case "http":
1216
1391
  case "https":
@@ -1224,10 +1399,10 @@ async function loadDiscImage(discImage) {
1224
1399
  discData = unzipped.data;
1225
1400
  discImage = unzipped.name;
1226
1401
  }
1227
- return disc.discFor(processor.fdc, discImage, discData);
1402
+ return disc.discFor(processor.fdc, discImage, discData, undefined, layout);
1228
1403
  }
1229
1404
  default:
1230
- return disc.discFor(processor.fdc, discImage, await disc.load("discs/" + discImage));
1405
+ return disc.discFor(processor.fdc, discImage, await disc.load("discs/" + discImage), undefined, layout);
1231
1406
  }
1232
1407
  }
1233
1408
 
@@ -1238,8 +1413,10 @@ async function loadTapeImage(tapeImage) {
1238
1413
 
1239
1414
  switch (schema) {
1240
1415
  case "|":
1241
- case "sth":
1242
- return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), model);
1416
+ case "sth": {
1417
+ const { name, data } = await tapeSth.fetch(tapeImage);
1418
+ return await loadTapeFromData(name, data, model);
1419
+ }
1243
1420
 
1244
1421
  case "data": {
1245
1422
  const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
@@ -1337,17 +1514,10 @@ function popupLoading(msg) {
1337
1514
  loadingDialogModal.show();
1338
1515
  }
1339
1516
 
1340
- function loadingFinished(error) {
1517
+ function loadingFinished(message) {
1341
1518
  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
- }
1519
+ loadingDialogModal.hide();
1520
+ if (message) toast(message);
1351
1521
  }
1352
1522
 
1353
1523
  const googleDrive = new GoogleDriveLoader();
@@ -1372,7 +1542,7 @@ document.querySelector("#google-drive-auth form").addEventListener("submit", asy
1372
1542
  else googleDriveLoadingReject(new Error("Unable to authorize Google Drive"));
1373
1543
  });
1374
1544
 
1375
- async function gdLoad(cat) {
1545
+ async function gdLoad(cat, layout) {
1376
1546
  // TODO: have a onclose flush event, handle errors
1377
1547
  /*
1378
1548
  $(window).bind("beforeunload", function() {
@@ -1396,13 +1566,13 @@ async function gdLoad(cat) {
1396
1566
  });
1397
1567
  }
1398
1568
 
1399
- const ssd = await googleDrive.load(processor.fdc, cat.id);
1569
+ const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
1400
1570
  console.log("Google Drive loading finished");
1401
1571
  loadingFinished();
1402
1572
  return ssd;
1403
1573
  } catch (error) {
1404
1574
  console.error("Google Drive loading error:", error);
1405
- loadingFinished(error);
1575
+ loadingFinished(`Unable to load ${cat.name} from Google Drive: ${errorText(error)}`);
1406
1576
  }
1407
1577
  }
1408
1578
 
@@ -1440,8 +1610,8 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
1440
1610
  utils.noteEvent("google-drive", "click", item.name);
1441
1611
  setDisc1Image(`gd:${item.id}/${item.name}`);
1442
1612
  googleDriveModal.hide();
1443
- const ssd = await gdLoad(item);
1444
- if (ssd) processor.fdc.loadDisc(0, ssd);
1613
+ const ssd = await gdLoad(item, layoutForDrive(0));
1614
+ if (ssd) putDiscIn(0, ssd);
1445
1615
  });
1446
1616
  }
1447
1617
  });
@@ -1457,7 +1627,7 @@ for (const image of availableImages) {
1457
1627
  utils.noteEvent("images", "click", image.file);
1458
1628
  setDisc1Image(image.file);
1459
1629
  $discsModal.hide();
1460
- processor.fdc.loadDisc(0, await loadDiscImage(parsedQuery.disc1));
1630
+ putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1461
1631
  });
1462
1632
  }
1463
1633
 
@@ -1476,7 +1646,7 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1476
1646
  try {
1477
1647
  data = discType.saver(processor.fdc.drives[0].disc);
1478
1648
  } catch (e) {
1479
- loadingFinished(`Create failed: ${e.message}`);
1649
+ loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(e)}`);
1480
1650
  return;
1481
1651
  }
1482
1652
  name = replaceOrAddExtension(name, discType.extension);
@@ -1497,11 +1667,11 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1497
1667
  try {
1498
1668
  const result = await googleDrive.create(processor.fdc, name, data);
1499
1669
  setDisc1Image("gd:" + result.fileId + "/" + name);
1500
- processor.fdc.loadDisc(0, result.disc);
1670
+ putDiscIn(0, result.disc);
1501
1671
  loadingFinished();
1502
1672
  } catch (error) {
1503
1673
  console.error(`Error creating Google Drive disc: ${error}`, error);
1504
- loadingFinished(`Create failed: ${error}`);
1674
+ loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(error)}`);
1505
1675
  }
1506
1676
  });
1507
1677
 
@@ -1567,6 +1737,9 @@ document.getElementById("save-state").addEventListener("click", async function (
1567
1737
  const discKey = driveIndex === 0 ? "disc1" : "disc2";
1568
1738
  const crcKey = discKey + "Crc32";
1569
1739
  media[crcKey] = driveDisc.originalImageCrc32;
1740
+ // The snapshot's dirty tracks are indexed by physical track, so restoring has to lay
1741
+ // the disc out the way this one was rather than work it out again.
1742
+ media[discKey + "Layout"] = driveDisc.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
1570
1743
  if (!media[discKey] && driveDisc.originalImageData) {
1571
1744
  media[discKey + "ImageData"] = driveDisc.originalImageData;
1572
1745
  media[discKey + "Name"] = driveDisc.name;
@@ -1742,90 +1915,85 @@ const startPromise = (async () => {
1742
1915
  // Ideally would start the loads first. But their completion needs the FDC from the processor
1743
1916
  const imageLoads = [];
1744
1917
 
1918
+ function startImageLoad(description, load) {
1919
+ const loading = (async () => {
1920
+ try {
1921
+ await load();
1922
+ } catch (error) {
1923
+ console.error(`Error loading ${description}:`, error);
1924
+ toast(`Could not load ${description}: ${error?.message ?? error}`, { title: "Loading" });
1925
+ }
1926
+ })();
1927
+ imageLoads.push(loading);
1928
+ return loading;
1929
+ }
1930
+
1745
1931
  if (discImage) {
1746
- imageLoads.push(
1747
- (async () => {
1748
- const disc = await loadDiscImage(discImage);
1749
- processor.fdc.loadDisc(0, disc);
1750
- })(),
1932
+ startImageLoad(`disc ${discImage}`, async () =>
1933
+ putDiscIn(0, await loadDiscImage(discImage, layoutForDrive(0))),
1751
1934
  );
1752
1935
  }
1753
1936
 
1754
1937
  if (secondDiscImage) {
1755
- imageLoads.push(
1756
- (async () => {
1757
- const disc = await loadDiscImage(secondDiscImage);
1758
- processor.fdc.loadDisc(1, disc);
1759
- })(),
1938
+ startImageLoad(`disc ${secondDiscImage}`, async () =>
1939
+ putDiscIn(1, await loadDiscImage(secondDiscImage, layoutForDrive(1))),
1760
1940
  );
1761
1941
  }
1762
1942
 
1763
1943
  if (parsedQuery.tape) {
1764
- imageLoads.push(
1765
- (async () => {
1766
- const tape = await loadTapeImage(parsedQuery.tape);
1767
- setProcessorTape(tape);
1768
- })(),
1769
- );
1944
+ startImageLoad(`tape ${parsedQuery.tape}`, async () => setProcessorTape(await loadTapeImage(parsedQuery.tape)));
1770
1945
  }
1771
1946
 
1772
1947
  if (mmcImage && model.isAtom) {
1773
- imageLoads.push(
1774
- (async () => {
1775
- const files = await LoadSD(mmcImage);
1776
- processor.atommc.SetMMCData(files);
1777
- })(),
1778
- );
1948
+ startImageLoad(`MMC image ${mmcImage}`, async () => processor.atommc.SetMMCData(await LoadSD(mmcImage)));
1779
1949
  }
1780
1950
 
1781
1951
  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
1952
+ const prog = await getBasicPromise;
1953
+ const t = await tokeniser.create();
1954
+ const tokenised = await t.tokenise(prog);
1955
+
1956
+ const idleAddr = processor.model.isMaster ? 0xe7e6 : 0xe581;
1957
+ const hook = processor.debugInstruction.add(function (addr) {
1958
+ if (addr !== idleAddr) return;
1959
+ const page = processor.readmem(0x18) << 8;
1960
+ for (let i = 0; i < tokenised.length; ++i) {
1961
+ processor.writemem(page + i, tokenised.charCodeAt(i));
1962
+ }
1963
+ // Set VARTOP (0x12/3) and TOP(0x02/3)
1964
+ const end = page + tokenised.length;
1965
+ const endLow = end & 0xff;
1966
+ const endHigh = (end >>> 8) & 0xff;
1967
+ processor.writemem(0x02, endLow);
1968
+ processor.writemem(0x03, endHigh);
1969
+ processor.writemem(0x12, endLow);
1970
+ processor.writemem(0x13, endHigh);
1971
+ hook.remove();
1972
+ if (needsRun) {
1973
+ autoRunBasic();
1974
+ }
1975
+ });
1812
1976
  }
1813
1977
 
1814
1978
  if (parsedQuery.loadBasic) {
1815
1979
  const needsRun = needsAutoboot === "run";
1816
1980
  needsAutoboot = "";
1817
1981
 
1818
- await insertBasic(
1819
- (async () => {
1820
- const data = await utils.loadData(parsedQuery.loadBasic);
1821
- return String.fromCharCode.apply(null, data);
1822
- })(),
1823
- needsRun,
1982
+ await startImageLoad(`BASIC program ${parsedQuery.loadBasic}`, () =>
1983
+ insertBasic(
1984
+ (async () => {
1985
+ const data = await utils.loadData(parsedQuery.loadBasic);
1986
+ return String.fromCharCode.apply(null, data);
1987
+ })(),
1988
+ needsRun,
1989
+ ),
1824
1990
  );
1825
1991
  }
1826
1992
 
1827
1993
  if (parsedQuery.embedBasic) {
1828
- await insertBasic(Promise.resolve(parsedQuery.embedBasic), true);
1994
+ await startImageLoad("the BASIC program from the URL", () =>
1995
+ insertBasic(Promise.resolve(parsedQuery.embedBasic), true),
1996
+ );
1829
1997
  }
1830
1998
 
1831
1999
  return Promise.all(imageLoads);
@@ -1837,7 +2005,7 @@ const startPromise = (async () => {
1837
2005
 
1838
2006
  switch (needsAutoboot) {
1839
2007
  case "boot":
1840
- sthAutoboot.checked = true;
2008
+ showAutoboot(true);
1841
2009
  autoboot(discImage);
1842
2010
  break;
1843
2011
  case "type":
@@ -1850,7 +2018,7 @@ const startPromise = (async () => {
1850
2018
  autoRunTape();
1851
2019
  break;
1852
2020
  default:
1853
- sthAutoboot.checked = false;
2021
+ showAutoboot(false);
1854
2022
  break;
1855
2023
  }
1856
2024
 
@@ -1998,6 +2166,23 @@ rewindUI.updateButtonState();
1998
2166
  if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
1999
2167
  else document.getElementById("disc-visualiser-open").classList.add("disabled");
2000
2168
 
2169
+ for (const item of document.querySelectorAll(".drive-tracks")) {
2170
+ const driveIndex = Number(item.dataset.drive);
2171
+ const drive = processor.fdc?.drives[driveIndex];
2172
+ const fixed = drive ? tracksPerStepForDrive(driveIndex) : undefined;
2173
+ if (fixed !== undefined) drive.tracksPerStep = fixed;
2174
+ for (const button of driveTracksButtons(driveIndex)) {
2175
+ button.disabled = !drive;
2176
+ button.addEventListener("click", (event) => {
2177
+ // Setting a switch is not picking from a menu, so leave the menu where it is.
2178
+ event.stopPropagation();
2179
+ drive.tracksPerStep = tracksPerStepFor(button.dataset.tracks);
2180
+ showDriveTracks(driveIndex);
2181
+ });
2182
+ }
2183
+ if (drive) showDriveTracks(driveIndex);
2184
+ }
2185
+
2001
2186
  function draw(now) {
2002
2187
  if (!running) {
2003
2188
  last = 0;