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/README.md +8 -2
- package/package.json +8 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/canvas.js +9 -2
- package/src/disc-drive.js +45 -12
- package/src/disc-hfe.js +18 -1
- package/src/disc.js +132 -20
- package/src/fdc.js +83 -11
- package/src/google-drive.js +4 -4
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +65 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +324 -139
- package/src/models.js +2 -0
- package/src/sth.js +5 -5
- package/src/teletext_adaptor.js +6 -4
- package/src/url-params.js +28 -0
- package/src/utils.js +3 -0
- package/src/wd-fdc.js +8 -1
- package/src/web/audio-handler.js +4 -2
- package/src/web/toast.js +79 -0
- package/tests/test-machine.js +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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
|
|
943
|
-
for (const el of document.querySelectorAll(
|
|
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
|
-
|
|
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
|
|
964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1517
|
+
function loadingFinished(message) {
|
|
1341
1518
|
googleDriveAuth.style.display = "none";
|
|
1342
|
-
|
|
1343
|
-
|
|
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)
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
1747
|
-
(
|
|
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
|
-
|
|
1756
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
|
1819
|
-
(
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|