jsbeeb 1.15.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";
@@ -44,14 +46,17 @@ import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
44
46
  import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
45
47
  import { RewindBuffer } from "./rewind.js";
46
48
  import { RewindUI } from "./rewind-ui.js";
49
+ import { DiscVisualiser } from "./disc-visualiser.js";
47
50
  import { downloadBlob } from "./dom-utils.js";
48
51
  import {
49
52
  buildUrlFromParams,
53
+ DriveTracks,
50
54
  guessModelFromHostname,
51
55
  ParamTypes,
52
56
  parseMediaParams,
53
57
  parseQueryString,
54
58
  processAutobootParams,
59
+ processDriveTrackParams,
55
60
  processInputParams,
56
61
  } from "./url-params.js";
57
62
 
@@ -64,6 +69,7 @@ let frameSkip = 0;
64
69
  let syncLights;
65
70
  let discSth;
66
71
  let tapeSth;
72
+ let hfeArchive;
67
73
  let running;
68
74
  let model;
69
75
 
@@ -152,6 +158,8 @@ const paramTypes = {
152
158
  keyLayout: ParamTypes.STRING,
153
159
  autotype: ParamTypes.STRING,
154
160
  displayMode: ParamTypes.STRING,
161
+ drive0Tracks: ParamTypes.STRING,
162
+ drive1Tracks: ParamTypes.STRING,
155
163
  };
156
164
 
157
165
  // Parse the query string with parameter types
@@ -172,6 +180,7 @@ let econet = null;
172
180
 
173
181
  // Parse disc and tape images from query parameters
174
182
  const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
183
+ const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
175
184
 
176
185
  // Only assign if values are provided
177
186
  if (queryDiscImage) discImage = queryDiscImage;
@@ -232,9 +241,10 @@ speechOutput.enabled = !!parsedQuery.speechOutput;
232
241
  const config = new Config(
233
242
  function onChange(changed) {
234
243
  if (changed.displayMode) {
235
- displayModeFilter = getFilterForMode(changed.displayMode);
244
+ // swapCanvas settles displayModeFilter on whatever was really
245
+ // built, so take the picture from that rather than the request.
246
+ swapCanvas(getFilterForMode(changed.displayMode));
236
247
  setCrtPic(displayModeFilter);
237
- swapCanvas(displayModeFilter);
238
248
  // Trigger window resize to recalculate layout with new dimensions
239
249
  window.dispatchEvent(new Event("resize"));
240
250
  }
@@ -280,7 +290,13 @@ const config = new Config(
280
290
  // Perform mapping of legacy models to the new format
281
291
  config.mapLegacyModels(parsedQuery);
282
292
 
283
- 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);
284
300
  config.setKeyLayout(keyLayout);
285
301
  config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
286
302
  config.setMicrophoneChannel(parsedQuery.microphoneChannel);
@@ -352,7 +368,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
352
368
  });
353
369
 
354
370
  if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
355
- const cpuSpeed = model.clockMhz * 1000 * 1000;
371
+ const cpuSpeed = model.cyclesPerSecond;
356
372
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
357
373
  const MaxCyclesPerFrame = clocksPerSecond / 10;
358
374
 
@@ -381,16 +397,87 @@ function showError(context, error) {
381
397
  errorDialogModal.show();
382
398
  }
383
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
+
384
407
  if (keyMappingWarnings.length) {
385
- 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
+ });
386
459
  }
387
460
 
388
461
  function createCanvasForFilter(filterClass) {
462
+ // Not `config`: that is the emulator's live configuration object, declared
463
+ // at module scope and used throughout this file.
464
+ 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.
467
+ screenCanvas.width = displayConfig.canvasWidth;
468
+ screenCanvas.height = displayConfig.canvasHeight;
469
+
389
470
  const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
390
471
 
391
- if (filterClass.requiresGl() && !newCanvas.isWebGl()) {
392
- const config = filterClass.getDisplayConfig();
393
- showError(`enabling ${config.name} mode`, `${config.name} requires WebGL. Using standard display instead.`);
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
+ });
394
481
  }
395
482
 
396
483
  return newCanvas;
@@ -398,20 +485,33 @@ function createCanvasForFilter(filterClass) {
398
485
 
399
486
  let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
400
487
  function swapCanvas(newFilterClass) {
488
+ const oldCanvas = canvas;
401
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();
402
495
  video.fb32 = newCanvas.fb32;
403
496
  video.paint_ext = function paint(minx, miny, maxx, maxy) {
404
497
  frames++;
405
498
  if (frames < frameSkip) return;
406
499
  frames = 0;
407
- newCanvas.paint(minx, miny, maxx, maxy, this.frameCount);
500
+ newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
408
501
  };
409
502
  canvas = newCanvas;
410
- displayModeFilter = newFilterClass;
503
+ // 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;
507
+ // Nothing else will redraw: the mode is changed from a modal, which stops
508
+ // the emulator.
509
+ video.paint();
411
510
  window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
412
511
  }
413
512
 
414
513
  let canvas = createCanvasForFilter(displayModeFilter);
514
+ displayModeFilter = canvas.filterClass;
415
515
 
416
516
  video = new Video(
417
517
  model.isMaster,
@@ -420,7 +520,7 @@ video = new Video(
420
520
  frames++;
421
521
  if (frames < frameSkip) return;
422
522
  frames = 0;
423
- canvas.paint(minx, miny, maxx, maxy, this.frameCount);
523
+ canvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
424
524
  },
425
525
  { isAtom: model.isAtom },
426
526
  );
@@ -488,10 +588,10 @@ function downloadDriveData(data, name, extension) {
488
588
 
489
589
  async function loadHTMLFile(file) {
490
590
  const imageData = utils.stringToUint8Array(await readFileAsBinaryString(file));
491
- const loadedDisc = disc.discFor(processor.fdc, file.name, imageData);
591
+ const loadedDisc = disc.discFor(processor.fdc, file.name, imageData, undefined, layoutForDrive(0));
492
592
  // Local file: retain the image bytes for embedding in save-to-file snapshots.
493
593
  loadedDisc.setOriginalImage(imageData);
494
- processor.fdc.loadDisc(0, loadedDisc);
594
+ putDiscIn(0, loadedDisc);
495
595
  delete parsedQuery.disc;
496
596
  delete parsedQuery.disc1;
497
597
  updateUrl();
@@ -533,7 +633,7 @@ pastetext.addEventListener("drop", async function (event) {
533
633
  await loadStateFromFile(file, arrayBuffer);
534
634
  } else if (file.name.toLowerCase().endsWith(".uef")) {
535
635
  // Regular UEF tape image (not a BeebEm save state)
536
- setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model.isAtom));
636
+ setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
537
637
  } else {
538
638
  await loadHTMLFile(file);
539
639
  }
@@ -619,7 +719,7 @@ window.addEventListener("beforeunload", function (event) {
619
719
  });
620
720
 
621
721
  if (config.hasEconet) {
622
- econet = new Econet(stationId);
722
+ econet = new Econet(stationId, model.cyclesPerSecond);
623
723
  } else {
624
724
  document.getElementById("fsmenuitem").style.display = "none";
625
725
  }
@@ -668,14 +768,14 @@ processor = new CpuClass(model, {
668
768
  econet,
669
769
  });
670
770
 
671
- processor.teletextAdaptor?.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
771
+ processor.teletextAdaptor?.addEventListener("notice", showNotice);
672
772
 
673
773
  // Create input sources
674
774
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
675
775
  // Create MicrophoneInput but don't enable by default
676
776
  const microphoneInput = new MicrophoneInput();
677
777
  microphoneInput.setErrorCallback((message) => {
678
- showError("accessing microphone", message);
778
+ toast(`${message} The microphone channel has been turned off.`, { title: "Microphone" });
679
779
  });
680
780
 
681
781
  // Create MouseJoystickSource but don't enable by default
@@ -776,7 +876,7 @@ keyboard = new Keyboard({
776
876
  keyLayout,
777
877
  dbgr,
778
878
  });
779
- keyboard.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
879
+ keyboard.addEventListener("notice", showNotice);
780
880
  keyboard.addEventListener("pause", () => stop(false));
781
881
  keyboard.addEventListener("resume", () => go());
782
882
  keyboard.addEventListener("break", (e) => {
@@ -911,15 +1011,26 @@ function setTapeImage(name) {
911
1011
  config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
912
1012
  }
913
1013
 
914
- function sthClearList() {
915
- 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
+ }
916
1030
  }
917
1031
 
918
1032
  function sthStartLoad() {
919
- const sthLoading = document.querySelector("#sth .loading");
920
- sthLoading.textContent = "Loading catalog from STH archive";
921
- sthLoading.style.display = "";
922
- sthClearList();
1033
+ showArchiveMessage("sth", "sth-list", "Loading catalog from STH archive");
923
1034
  }
924
1035
 
925
1036
  async function discSthClick(item) {
@@ -932,8 +1043,8 @@ async function discSthClick(item) {
932
1043
 
933
1044
  popupLoading("Loading " + item);
934
1045
  try {
935
- const disc = await loadDiscImage(parsedQuery.disc1);
936
- processor.fdc.loadDisc(0, disc);
1046
+ const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
1047
+ putDiscIn(0, loaded);
937
1048
  loadingFinished();
938
1049
 
939
1050
  if (needsAutoboot) {
@@ -941,7 +1052,7 @@ async function discSthClick(item) {
941
1052
  }
942
1053
  } catch (err) {
943
1054
  console.error("Error loading disc image:", err);
944
- loadingFinished(err);
1055
+ loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
945
1056
  }
946
1057
  }
947
1058
 
@@ -956,7 +1067,7 @@ async function tapeSthClick(item) {
956
1067
  loadingFinished();
957
1068
  } catch (err) {
958
1069
  console.error("Error loading tape image:", err);
959
- loadingFinished(err);
1070
+ loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
960
1071
  }
961
1072
  }
962
1073
 
@@ -967,7 +1078,7 @@ document.getElementById("sth").addEventListener("shown.bs.modal", () => {
967
1078
 
968
1079
  function makeOnCat(onClick) {
969
1080
  return function (cat) {
970
- sthClearList();
1081
+ clearArchiveList("sth-list");
971
1082
  const sthList = document.getElementById("sth-list");
972
1083
  document.querySelector("#sth .loading").style.display = "none";
973
1084
  const template = sthList.querySelector(".template");
@@ -997,24 +1108,30 @@ function makeOnCat(onClick) {
997
1108
  }
998
1109
 
999
1110
  function sthOnError() {
1000
- const sthLoading = document.querySelector("#sth .loading");
1001
- sthLoading.textContent = "There was an error accessing the STH archive";
1002
- sthLoading.style.display = "";
1003
- sthClearList();
1111
+ showArchiveMessage("sth", "sth-list", "There was an error accessing the STH archive");
1004
1112
  }
1005
1113
 
1006
1114
  discSth = new StairwayToHell(sthStartLoad, makeOnCat(discSthClick), sthOnError, false);
1007
1115
  tapeSth = new StairwayToHell(sthStartLoad, makeOnCat(tapeSthClick), sthOnError, true);
1008
-
1009
- const sthAutoboot = document.querySelector("#sth .autoboot");
1010
- sthAutoboot.addEventListener("click", function () {
1011
- if (sthAutoboot.checked) {
1012
- parsedQuery.autoboot = "";
1013
- } else {
1014
- delete parsedQuery.autoboot;
1015
- }
1016
- updateUrl();
1017
- });
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
+ }
1018
1135
 
1019
1136
  document.addEventListener("click", function (e) {
1020
1137
  const target = e.target.closest("a.sth");
@@ -1030,16 +1147,94 @@ document.addEventListener("click", function (e) {
1030
1147
  });
1031
1148
 
1032
1149
  function setSthFilter(filter) {
1033
- filter = filter.toLowerCase();
1034
- for (const el of document.querySelectorAll("#sth-list li:not(.template)")) {
1035
- el.style.display = el.textContent.toLowerCase().indexOf(filter) >= 0 ? "" : "none";
1036
- }
1150
+ filterArchiveList("sth-list", filter);
1037
1151
  }
1038
1152
 
1039
1153
  const sthFilter = document.getElementById("sth-filter");
1040
1154
  sthFilter.addEventListener("change", () => setSthFilter(sthFilter.value));
1041
1155
  sthFilter.addEventListener("keyup", () => setSthFilter(sthFilter.value));
1042
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
+
1043
1238
  function sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
1044
1239
  if (keyboard) {
1045
1240
  keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
@@ -1110,10 +1305,13 @@ async function reloadSnapshotMedia(media) {
1110
1305
  const imageDataKey = discKey + "ImageData";
1111
1306
  const crcKey = discKey + "Crc32";
1112
1307
 
1308
+ // A snapshot from before layout detection has no field, and was contiguous.
1309
+ const layout = media[discKey + "Layout"] ?? DiscLayout.contiguous;
1310
+
1113
1311
  let loadedDisc = null;
1114
1312
  if (media[discKey]) {
1115
1313
  // URL-based disc — reload from source
1116
- loadedDisc = await loadDiscImage(media[discKey]);
1314
+ loadedDisc = await loadDiscImage(media[discKey], layout);
1117
1315
  } else if (media[imageDataKey]) {
1118
1316
  // Locally-loaded disc — reconstruct from embedded image data
1119
1317
  const imageData =
@@ -1121,7 +1319,7 @@ async function reloadSnapshotMedia(media) {
1121
1319
  ? media[imageDataKey]
1122
1320
  : new Uint8Array(Object.values(media[imageDataKey]));
1123
1321
  const discName = media[discKey + "Name"] || "snapshot.ssd";
1124
- loadedDisc = disc.discFor(processor.fdc, discName, imageData);
1322
+ loadedDisc = disc.discFor(processor.fdc, discName, imageData, undefined, layout);
1125
1323
  // Retain the image bytes so subsequent saves can re-embed them.
1126
1324
  loadedDisc.setOriginalImage(imageData);
1127
1325
  }
@@ -1130,14 +1328,14 @@ async function reloadSnapshotMedia(media) {
1130
1328
  // Verify CRC32 if present
1131
1329
  if (media[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
1132
1330
  if (loadedDisc.originalImageCrc32 !== media[crcKey]) {
1133
- showError(
1134
- "loading state",
1135
- "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" },
1136
1334
  );
1137
1335
  }
1138
1336
  }
1139
1337
 
1140
- processor.fdc.loadDisc(driveIndex, loadedDisc);
1338
+ putDiscIn(driveIndex, loadedDisc);
1141
1339
  // Only update the URL/query for URL-sourced discs. For embedded
1142
1340
  // (local-file) discs, setting parsedQuery would put a bogus source
1143
1341
  // in the URL and break subsequent saves/reloads.
@@ -1148,13 +1346,13 @@ async function reloadSnapshotMedia(media) {
1148
1346
  }
1149
1347
  }
1150
1348
 
1151
- async function loadDiscImage(discImage) {
1349
+ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1152
1350
  if (!discImage) return null;
1153
1351
  const split = splitImage(discImage);
1154
1352
  discImage = split.image;
1155
1353
  const schema = split.schema;
1156
1354
  if (schema[0] === "!" || schema === "local") {
1157
- return disc.localDisc(processor.fdc, discImage);
1355
+ return disc.localDisc(processor.fdc, discImage, layout);
1158
1356
  }
1159
1357
  // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
1160
1358
  // Consider:
@@ -1164,8 +1362,13 @@ async function loadDiscImage(discImage) {
1164
1362
  // * Dialog box (ugh) saying "is this ok?"
1165
1363
  switch (schema) {
1166
1364
  case "|":
1167
- case "sth":
1168
- 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));
1169
1372
 
1170
1373
  case "gd": {
1171
1374
  const splat = discImage.match(/([^/]+)\/?(.*)/);
@@ -1174,15 +1377,15 @@ async function loadDiscImage(discImage) {
1174
1377
  discImage = splat[1];
1175
1378
  name = splat[2];
1176
1379
  }
1177
- return gdLoad({ name, id: discImage });
1380
+ return gdLoad({ name, id: discImage }, layout);
1178
1381
  }
1179
1382
  case "b64data":
1180
- return disc.discFor(processor.fdc, "disk.ssd", atob(discImage));
1383
+ return disc.discFor(processor.fdc, "disk.ssd", atob(discImage), undefined, layout);
1181
1384
 
1182
1385
  case "data": {
1183
1386
  const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
1184
1387
  const { name, data } = await utils.unzipDiscImage(arr);
1185
- return disc.discFor(processor.fdc, name, data);
1388
+ return disc.discFor(processor.fdc, name, data, undefined, layout);
1186
1389
  }
1187
1390
  case "http":
1188
1391
  case "https":
@@ -1196,10 +1399,10 @@ async function loadDiscImage(discImage) {
1196
1399
  discData = unzipped.data;
1197
1400
  discImage = unzipped.name;
1198
1401
  }
1199
- return disc.discFor(processor.fdc, discImage, discData);
1402
+ return disc.discFor(processor.fdc, discImage, discData, undefined, layout);
1200
1403
  }
1201
1404
  default:
1202
- 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);
1203
1406
  }
1204
1407
  }
1205
1408
 
@@ -1207,17 +1410,18 @@ async function loadTapeImage(tapeImage) {
1207
1410
  const split = splitImage(tapeImage);
1208
1411
  tapeImage = split.image;
1209
1412
  const schema = split.schema;
1210
- const isAtom = model.isAtom;
1211
1413
 
1212
1414
  switch (schema) {
1213
1415
  case "|":
1214
- case "sth":
1215
- return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), isAtom);
1416
+ case "sth": {
1417
+ const { name, data } = await tapeSth.fetch(tapeImage);
1418
+ return await loadTapeFromData(name, data, model);
1419
+ }
1216
1420
 
1217
1421
  case "data": {
1218
1422
  const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
1219
1423
  const { name, data } = await utils.unzipDiscImage(arr);
1220
- return await loadTapeFromData(name, data, isAtom);
1424
+ return await loadTapeFromData(name, data, model);
1221
1425
  }
1222
1426
 
1223
1427
  case "http":
@@ -1232,7 +1436,7 @@ async function loadTapeImage(tapeImage) {
1232
1436
  tapeData = unzipped.data;
1233
1437
  tapeImage = unzipped.name;
1234
1438
  }
1235
- return await loadTapeFromData(tapeImage, tapeData, isAtom);
1439
+ return await loadTapeFromData(tapeImage, tapeData, model);
1236
1440
  }
1237
1441
 
1238
1442
  default: {
@@ -1244,7 +1448,7 @@ async function loadTapeImage(tapeImage) {
1244
1448
  tapeData = unzipped.data;
1245
1449
  tapeName = unzipped.name;
1246
1450
  }
1247
- return await loadTapeFromData(tapeName, tapeData, isAtom);
1451
+ return await loadTapeFromData(tapeName, tapeData, model);
1248
1452
  }
1249
1453
  }
1250
1454
  }
@@ -1277,7 +1481,7 @@ document.getElementById("tape_load").addEventListener("change", async function (
1277
1481
  tapeData = unzipped.data;
1278
1482
  tapeName = unzipped.name;
1279
1483
  }
1280
- setProcessorTape(await loadTapeFromData(tapeName, tapeData, model.isAtom));
1484
+ setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1281
1485
  delete parsedQuery.tape;
1282
1486
  updateUrl();
1283
1487
  bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
@@ -1310,17 +1514,10 @@ function popupLoading(msg) {
1310
1514
  loadingDialogModal.show();
1311
1515
  }
1312
1516
 
1313
- function loadingFinished(error) {
1517
+ function loadingFinished(message) {
1314
1518
  googleDriveAuth.style.display = "none";
1315
- if (error) {
1316
- loadingDialogModal.show();
1317
- loadingDialog.querySelector(".loading").textContent = "Error: " + error;
1318
- setTimeout(function () {
1319
- loadingDialogModal.hide();
1320
- }, 5000);
1321
- } else {
1322
- loadingDialogModal.hide();
1323
- }
1519
+ loadingDialogModal.hide();
1520
+ if (message) toast(message);
1324
1521
  }
1325
1522
 
1326
1523
  const googleDrive = new GoogleDriveLoader();
@@ -1345,7 +1542,7 @@ document.querySelector("#google-drive-auth form").addEventListener("submit", asy
1345
1542
  else googleDriveLoadingReject(new Error("Unable to authorize Google Drive"));
1346
1543
  });
1347
1544
 
1348
- async function gdLoad(cat) {
1545
+ async function gdLoad(cat, layout) {
1349
1546
  // TODO: have a onclose flush event, handle errors
1350
1547
  /*
1351
1548
  $(window).bind("beforeunload", function() {
@@ -1369,13 +1566,13 @@ async function gdLoad(cat) {
1369
1566
  });
1370
1567
  }
1371
1568
 
1372
- const ssd = await googleDrive.load(processor.fdc, cat.id);
1569
+ const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
1373
1570
  console.log("Google Drive loading finished");
1374
1571
  loadingFinished();
1375
1572
  return ssd;
1376
1573
  } catch (error) {
1377
1574
  console.error("Google Drive loading error:", error);
1378
- loadingFinished(error);
1575
+ loadingFinished(`Unable to load ${cat.name} from Google Drive: ${errorText(error)}`);
1379
1576
  }
1380
1577
  }
1381
1578
 
@@ -1413,8 +1610,8 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
1413
1610
  utils.noteEvent("google-drive", "click", item.name);
1414
1611
  setDisc1Image(`gd:${item.id}/${item.name}`);
1415
1612
  googleDriveModal.hide();
1416
- const ssd = await gdLoad(item);
1417
- if (ssd) processor.fdc.loadDisc(0, ssd);
1613
+ const ssd = await gdLoad(item, layoutForDrive(0));
1614
+ if (ssd) putDiscIn(0, ssd);
1418
1615
  });
1419
1616
  }
1420
1617
  });
@@ -1430,7 +1627,7 @@ for (const image of availableImages) {
1430
1627
  utils.noteEvent("images", "click", image.file);
1431
1628
  setDisc1Image(image.file);
1432
1629
  $discsModal.hide();
1433
- processor.fdc.loadDisc(0, await loadDiscImage(parsedQuery.disc1));
1630
+ putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1434
1631
  });
1435
1632
  }
1436
1633
 
@@ -1449,7 +1646,7 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1449
1646
  try {
1450
1647
  data = discType.saver(processor.fdc.drives[0].disc);
1451
1648
  } catch (e) {
1452
- loadingFinished(`Create failed: ${e.message}`);
1649
+ loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(e)}`);
1453
1650
  return;
1454
1651
  }
1455
1652
  name = replaceOrAddExtension(name, discType.extension);
@@ -1470,11 +1667,11 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1470
1667
  try {
1471
1668
  const result = await googleDrive.create(processor.fdc, name, data);
1472
1669
  setDisc1Image("gd:" + result.fileId + "/" + name);
1473
- processor.fdc.loadDisc(0, result.disc);
1670
+ putDiscIn(0, result.disc);
1474
1671
  loadingFinished();
1475
1672
  } catch (error) {
1476
1673
  console.error(`Error creating Google Drive disc: ${error}`, error);
1477
- loadingFinished(`Create failed: ${error}`);
1674
+ loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(error)}`);
1478
1675
  }
1479
1676
  });
1480
1677
 
@@ -1540,6 +1737,9 @@ document.getElementById("save-state").addEventListener("click", async function (
1540
1737
  const discKey = driveIndex === 0 ? "disc1" : "disc2";
1541
1738
  const crcKey = discKey + "Crc32";
1542
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;
1543
1743
  if (!media[discKey] && driveDisc.originalImageData) {
1544
1744
  media[discKey + "ImageData"] = driveDisc.originalImageData;
1545
1745
  media[discKey + "Name"] = driveDisc.name;
@@ -1715,90 +1915,85 @@ const startPromise = (async () => {
1715
1915
  // Ideally would start the loads first. But their completion needs the FDC from the processor
1716
1916
  const imageLoads = [];
1717
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
+
1718
1931
  if (discImage) {
1719
- imageLoads.push(
1720
- (async () => {
1721
- const disc = await loadDiscImage(discImage);
1722
- processor.fdc.loadDisc(0, disc);
1723
- })(),
1932
+ startImageLoad(`disc ${discImage}`, async () =>
1933
+ putDiscIn(0, await loadDiscImage(discImage, layoutForDrive(0))),
1724
1934
  );
1725
1935
  }
1726
1936
 
1727
1937
  if (secondDiscImage) {
1728
- imageLoads.push(
1729
- (async () => {
1730
- const disc = await loadDiscImage(secondDiscImage);
1731
- processor.fdc.loadDisc(1, disc);
1732
- })(),
1938
+ startImageLoad(`disc ${secondDiscImage}`, async () =>
1939
+ putDiscIn(1, await loadDiscImage(secondDiscImage, layoutForDrive(1))),
1733
1940
  );
1734
1941
  }
1735
1942
 
1736
1943
  if (parsedQuery.tape) {
1737
- imageLoads.push(
1738
- (async () => {
1739
- const tape = await loadTapeImage(parsedQuery.tape);
1740
- setProcessorTape(tape);
1741
- })(),
1742
- );
1944
+ startImageLoad(`tape ${parsedQuery.tape}`, async () => setProcessorTape(await loadTapeImage(parsedQuery.tape)));
1743
1945
  }
1744
1946
 
1745
1947
  if (mmcImage && model.isAtom) {
1746
- imageLoads.push(
1747
- (async () => {
1748
- const files = await LoadSD(mmcImage);
1749
- processor.atommc.SetMMCData(files);
1750
- })(),
1751
- );
1948
+ startImageLoad(`MMC image ${mmcImage}`, async () => processor.atommc.SetMMCData(await LoadSD(mmcImage)));
1752
1949
  }
1753
1950
 
1754
1951
  async function insertBasic(getBasicPromise, needsRun) {
1755
- const basicLoadPromise = (async () => {
1756
- const prog = await getBasicPromise;
1757
- const t = await tokeniser.create();
1758
- const tokenised = await t.tokenise(prog);
1759
-
1760
- const idleAddr = processor.model.isMaster ? 0xe7e6 : 0xe581;
1761
- const hook = processor.debugInstruction.add(function (addr) {
1762
- if (addr !== idleAddr) return;
1763
- const page = processor.readmem(0x18) << 8;
1764
- for (let i = 0; i < tokenised.length; ++i) {
1765
- processor.writemem(page + i, tokenised.charCodeAt(i));
1766
- }
1767
- // Set VARTOP (0x12/3) and TOP(0x02/3)
1768
- const end = page + tokenised.length;
1769
- const endLow = end & 0xff;
1770
- const endHigh = (end >>> 8) & 0xff;
1771
- processor.writemem(0x02, endLow);
1772
- processor.writemem(0x03, endHigh);
1773
- processor.writemem(0x12, endLow);
1774
- processor.writemem(0x13, endHigh);
1775
- hook.remove();
1776
- if (needsRun) {
1777
- autoRunBasic();
1778
- }
1779
- });
1780
- return tokenised; // Explicitly return the result
1781
- })();
1782
-
1783
- imageLoads.push(basicLoadPromise);
1784
- 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
+ });
1785
1976
  }
1786
1977
 
1787
1978
  if (parsedQuery.loadBasic) {
1788
1979
  const needsRun = needsAutoboot === "run";
1789
1980
  needsAutoboot = "";
1790
1981
 
1791
- await insertBasic(
1792
- (async () => {
1793
- const data = await utils.loadData(parsedQuery.loadBasic);
1794
- return String.fromCharCode.apply(null, data);
1795
- })(),
1796
- 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
+ ),
1797
1990
  );
1798
1991
  }
1799
1992
 
1800
1993
  if (parsedQuery.embedBasic) {
1801
- await insertBasic(Promise.resolve(parsedQuery.embedBasic), true);
1994
+ await startImageLoad("the BASIC program from the URL", () =>
1995
+ insertBasic(Promise.resolve(parsedQuery.embedBasic), true),
1996
+ );
1802
1997
  }
1803
1998
 
1804
1999
  return Promise.all(imageLoads);
@@ -1810,7 +2005,7 @@ const startPromise = (async () => {
1810
2005
 
1811
2006
  switch (needsAutoboot) {
1812
2007
  case "boot":
1813
- sthAutoboot.checked = true;
2008
+ showAutoboot(true);
1814
2009
  autoboot(discImage);
1815
2010
  break;
1816
2011
  case "type":
@@ -1823,7 +2018,7 @@ const startPromise = (async () => {
1823
2018
  autoRunTape();
1824
2019
  break;
1825
2020
  default:
1826
- sthAutoboot.checked = false;
2021
+ showAutoboot(false);
1827
2022
  break;
1828
2023
  }
1829
2024
 
@@ -1940,7 +2135,7 @@ function VirtualSpeedUpdater() {
1940
2135
  if (this.cycles) {
1941
2136
  const thisMHz = this.cycles / this.time / 1000;
1942
2137
  this.v.textContent = thisMHz.toFixed(1);
1943
- if (this.cycles >= 10 * 2 * 1000 * 1000) {
2138
+ if (this.cycles >= 10 * cpuSpeed) {
1944
2139
  this.cycles = this.time = 0;
1945
2140
  }
1946
2141
  this.header.style.color = this.speedy ? "red" : "white";
@@ -1968,6 +2163,26 @@ rewindUI = new RewindUI({
1968
2163
  });
1969
2164
  rewindUI.updateButtonState();
1970
2165
 
2166
+ if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
2167
+ else document.getElementById("disc-visualiser-open").classList.add("disabled");
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
+
1971
2186
  function draw(now) {
1972
2187
  if (!running) {
1973
2188
  last = 0;
@@ -2083,6 +2298,9 @@ function stop(debug) {
2083
2298
  updateDebugButtons();
2084
2299
  }
2085
2300
 
2301
+ /** Steps the drawing buffer grows in, as a multiple of the base canvas size. */
2302
+ const CanvasScaleStep = 0.25;
2303
+
2086
2304
  (function () {
2087
2305
  const resizeCubMonitor = document.getElementById("cub-monitor");
2088
2306
  const resizeCubMonitorPic = document.getElementById("cub-monitor-pic");
@@ -2135,6 +2353,25 @@ function stop(debug) {
2135
2353
  resizeCubMonitor.style.width = width + "px";
2136
2354
  resizeCubMonitorPic.style.height = height + "px";
2137
2355
  resizeCubMonitorPic.style.width = width + "px";
2356
+ // A mode that reconstructs detail wants to draw at the size it will be
2357
+ // seen at, up to the limit it asks for. Drawing more than the display
2358
+ // can show costs fragments and buys nothing, and for an expensive
2359
+ // shader that is the difference between comfortable and not.
2360
+ if (displayConfig.maxCanvasScale) {
2361
+ const wanted = (finalCanvasWidth * (window.devicePixelRatio || 1)) / displayConfig.canvasWidth;
2362
+ // Quantised, because resize fires continuously while a window is
2363
+ // dragged and every distinct value reallocates the drawing buffer.
2364
+ const quantised = Math.round(wanted / CanvasScaleStep) * CanvasScaleStep;
2365
+ const scale = Math.min(displayConfig.maxCanvasScale, Math.max(1, quantised));
2366
+ const backingWidth = Math.round(displayConfig.canvasWidth * scale);
2367
+ if (screenCanvas.width !== backingWidth) {
2368
+ screenCanvas.width = backingWidth;
2369
+ screenCanvas.height = Math.round(displayConfig.canvasHeight * scale);
2370
+ // Resizing threw the drawing buffer away.
2371
+ video.paint();
2372
+ }
2373
+ }
2374
+
2138
2375
  screenCanvas.style.width = finalCanvasWidth + "px";
2139
2376
  screenCanvas.style.height = finalCanvasHeight + "px";
2140
2377
  screenCanvas.style.left = canvasOrigLeft * containerScale + "px";