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/README.md +8 -2
- package/package.json +15 -6
- package/src/6502.js +14 -7
- package/src/6847.js +50 -8
- package/src/acia.js +2 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/canvas.js +85 -19
- package/src/disc-drive.js +54 -12
- package/src/disc-hfe.js +19 -3
- package/src/disc-surface.js +350 -0
- package/src/disc-visualiser.js +569 -0
- package/src/disc.js +226 -62
- package/src/econet.js +6 -2
- package/src/fdc.js +83 -11
- package/src/google-drive.js +4 -4
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +191 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +389 -152
- package/src/models.js +15 -3
- package/src/sth.js +25 -22
- package/src/tapes.js +10 -12
- package/src/teletext_adaptor.js +6 -4
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- package/src/url-params.js +28 -0
- package/src/utils.js +3 -0
- package/src/video-filters/pal-composite.js +14 -48
- package/src/video-filters/passthrough-filter.js +11 -39
- package/src/video-filters/pixel-grid.js +55 -0
- package/src/video-filters/shader-program.js +53 -0
- package/src/video-filters/shaders/xbr.frag.glsl +278 -0
- package/src/video-filters/shaders/xbr.vert.glsl +7 -0
- package/src/video-filters/xbr-filter.js +107 -0
- package/src/video.js +56 -16
- package/src/wd-fdc.js +25 -8
- package/src/web/audio-handler.js +4 -2
- package/src/web/toast.js +79 -0
- package/tests/test-machine.js +8 -6
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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
|
|
915
|
-
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
|
+
}
|
|
916
1030
|
}
|
|
917
1031
|
|
|
918
1032
|
function sthStartLoad() {
|
|
919
|
-
|
|
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
|
|
936
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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(
|
|
1517
|
+
function loadingFinished(message) {
|
|
1314
1518
|
googleDriveAuth.style.display = "none";
|
|
1315
|
-
|
|
1316
|
-
|
|
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)
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
1720
|
-
(
|
|
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
|
-
|
|
1729
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
|
1792
|
-
(
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 *
|
|
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";
|