jsbeeb 1.17.0 → 1.18.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 -0
- package/package.json +5 -3
- package/src/6502.js +11 -2
- package/src/bbcdiscs.js +31 -1
- package/src/bem-snapshot.js +3 -4
- package/src/canvas.js +71 -29
- package/src/cmos.js +38 -0
- package/src/disc-hfe.js +1 -1
- package/src/disc.js +22 -2
- package/src/fdc.js +15 -2
- package/src/google-drive.js +2 -1
- package/src/jsbeeb.css +23 -1
- package/src/machine-session.js +57 -0
- package/src/main.js +227 -105
- package/src/printer.js +59 -0
- package/src/teletext.js +12 -2
- package/src/teletext_adaptor.js +40 -5
- package/src/touchscreen.js +24 -3
- package/src/utils.js +44 -57
- package/src/via.js +32 -9
- package/src/web/audio-handler.js +26 -9
- package/src/web/toast.js +5 -1
- package/tests/test-machine.js +3 -1
package/src/main.js
CHANGED
|
@@ -10,9 +10,9 @@ import { Debugger } from "./web/debug.js";
|
|
|
10
10
|
import { Cpu6502, AtomCpu6502 } from "./6502.js";
|
|
11
11
|
import * as utils_atom from "./utils_atom.js";
|
|
12
12
|
import { LoadSD } from "./mmc.js";
|
|
13
|
-
import { Cmos } from "./cmos.js";
|
|
13
|
+
import { Cmos, localStoragePersistence } from "./cmos.js";
|
|
14
14
|
import { StairwayToHell } from "./sth.js";
|
|
15
|
-
import { BbcDiscArchive, describe as describeHfe } from "./bbcdiscs.js";
|
|
15
|
+
import { BbcDiscArchive, Provenance, describe as describeHfe, matches, provenancesIn } from "./bbcdiscs.js";
|
|
16
16
|
import { GamePad } from "./gamepads.js";
|
|
17
17
|
import * as disc from "./fdc.js";
|
|
18
18
|
import { loadTapeFromData } from "./tapes.js";
|
|
@@ -31,6 +31,7 @@ import { GamepadSource } from "./gamepad-source.js";
|
|
|
31
31
|
import { toast } from "./web/toast.js";
|
|
32
32
|
import { MicrophoneInput } from "./microphone-input.js";
|
|
33
33
|
import { SpeechOutput } from "./speech-output.js";
|
|
34
|
+
import { Printer } from "./printer.js";
|
|
34
35
|
import { MouseJoystickSource } from "./mouse-joystick-source.js";
|
|
35
36
|
import { calculateMouseCoordinates } from "./mouse-coordinates.js";
|
|
36
37
|
import { getFilterForMode } from "./canvas.js";
|
|
@@ -208,19 +209,16 @@ if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilt
|
|
|
208
209
|
if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
|
|
209
210
|
if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
|
|
210
211
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
if (!output || level) return;
|
|
215
|
-
|
|
216
|
-
const uservia = processor.uservia;
|
|
217
|
-
// Ack the character by pulsing CA1 low.
|
|
218
|
-
uservia.setca1(false);
|
|
219
|
-
uservia.setca1(true);
|
|
220
|
-
const newChar = String.fromCharCode(uservia.ora);
|
|
221
|
-
printerTextArea.value += newChar;
|
|
212
|
+
const printer = new Printer({
|
|
213
|
+
onOutput: (char) => {
|
|
214
|
+
if (printerTextArea) printerTextArea.value += char;
|
|
222
215
|
},
|
|
223
|
-
|
|
216
|
+
onFirstOutput: () =>
|
|
217
|
+
toast("Printer output is being kept. Press Ctrl-B to open the printer window.", {
|
|
218
|
+
title: "Printer",
|
|
219
|
+
quietKey: "quietPrinterOutput",
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
224
222
|
|
|
225
223
|
// Accessibility switch state — bits 0-7 correspond to switches 1-8.
|
|
226
224
|
// Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
|
|
@@ -335,7 +333,7 @@ const emulationConfig = {
|
|
|
335
333
|
// before any the user asked for with ?rom=.
|
|
336
334
|
extraRoms: [...config.extraRoms, ...extraRoms],
|
|
337
335
|
userPort,
|
|
338
|
-
printerPort,
|
|
336
|
+
printerPort: printer,
|
|
339
337
|
getGamepads: function () {
|
|
340
338
|
// Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
|
|
341
339
|
return navigator.getGamepads ? navigator.getGamepads() : [];
|
|
@@ -399,6 +397,24 @@ function showError(context, error) {
|
|
|
399
397
|
|
|
400
398
|
const errorText = (error) => error?.message ?? `${error}`;
|
|
401
399
|
|
|
400
|
+
function reportLoadFailure(description, error) {
|
|
401
|
+
console.error(`Error loading ${description}:`, error);
|
|
402
|
+
toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function reportIgnoredFiles(name, ignored) {
|
|
406
|
+
if (!ignored.length) return;
|
|
407
|
+
toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
|
|
408
|
+
title: "Archive",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function unzipAndReport(data) {
|
|
413
|
+
const unzipped = await utils.unzipDiscImage(data);
|
|
414
|
+
reportIgnoredFiles(unzipped.name, unzipped.ignored);
|
|
415
|
+
return unzipped;
|
|
416
|
+
}
|
|
417
|
+
|
|
402
418
|
function showNotice(event) {
|
|
403
419
|
const { message, title, quietKey } = event.detail;
|
|
404
420
|
toast(message, { title, quietKey });
|
|
@@ -433,10 +449,25 @@ function putDiscIn(driveIndex, loadedDisc) {
|
|
|
433
449
|
const was = drive.tracksPerStep;
|
|
434
450
|
processor.fdc.loadDisc(driveIndex, loadedDisc, fixed);
|
|
435
451
|
showDriveTracks(driveIndex);
|
|
452
|
+
noteUnsavedWrites(loadedDisc);
|
|
436
453
|
// A switch the user fixed does not move, so anything it does is not news.
|
|
437
454
|
if (fixed === undefined && drive.tracksPerStep !== was) noteDriveTracks(driveIndex, loadedDisc.name);
|
|
438
455
|
}
|
|
439
456
|
|
|
457
|
+
let saidWritesAreNotKept = false;
|
|
458
|
+
|
|
459
|
+
function noteUnsavedWrites(loadedDisc) {
|
|
460
|
+
if (loadedDisc.savesChanges || saidWritesAreNotKept) return;
|
|
461
|
+
loadedDisc.notifyOnFirstTrackWrite(() => {
|
|
462
|
+
if (saidWritesAreNotKept) return;
|
|
463
|
+
saidWritesAreNotKept = true;
|
|
464
|
+
toast(`Changes to ${loadedDisc.name} are not saved. Use Discs, Download to keep a copy.`, {
|
|
465
|
+
title: "Disc",
|
|
466
|
+
quietKey: "quietDiscNotSaved",
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
440
471
|
const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
|
|
441
472
|
|
|
442
473
|
function showDriveTracks(driveIndex) {
|
|
@@ -458,59 +489,57 @@ function noteDriveTracks(driveIndex, discName) {
|
|
|
458
489
|
});
|
|
459
490
|
}
|
|
460
491
|
|
|
461
|
-
|
|
492
|
+
// Test which filter is actually in use, not merely whether we got WebGL: a
|
|
493
|
+
// filter can decline a context that works perfectly well for other modes, in
|
|
494
|
+
// which case we are quietly left with an unfiltered display.
|
|
495
|
+
function reportAnyFallback(displayCanvas, filterClass) {
|
|
496
|
+
if (displayCanvas.filterClass === filterClass) return;
|
|
497
|
+
const reason = displayCanvas.fallbackReason ? ` (${displayCanvas.fallbackReason})` : "";
|
|
498
|
+
const { name } = filterClass.getDisplayConfig();
|
|
499
|
+
toast(`${name} is not available on this device, so the standard display is in use${reason}.`, {
|
|
500
|
+
title: "Display",
|
|
501
|
+
quietKey: "quietDisplayFallback",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function sizeCanvasFor(filterClass) {
|
|
462
506
|
// Not `config`: that is the emulator's live configuration object, declared
|
|
463
507
|
// at module scope and used throughout this file.
|
|
464
508
|
const displayConfig = filterClass.getDisplayConfig();
|
|
465
|
-
|
|
466
|
-
// creating the context, which fixes its initial viewport.
|
|
509
|
+
if (screenCanvas.width === displayConfig.canvasWidth && screenCanvas.height === displayConfig.canvasHeight) return;
|
|
467
510
|
screenCanvas.width = displayConfig.canvasWidth;
|
|
468
511
|
screenCanvas.height = displayConfig.canvasHeight;
|
|
512
|
+
}
|
|
469
513
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
//
|
|
473
|
-
|
|
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
|
-
});
|
|
481
|
-
}
|
|
514
|
+
function createCanvasForFilter(filterClass) {
|
|
515
|
+
// Each mode says how many pixels it wants to draw into. Set this before
|
|
516
|
+
// creating the context, which fixes its initial viewport.
|
|
517
|
+
sizeCanvasFor(filterClass);
|
|
482
518
|
|
|
519
|
+
const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
|
|
520
|
+
reportAnyFallback(newCanvas, filterClass);
|
|
483
521
|
return newCanvas;
|
|
484
522
|
}
|
|
485
523
|
|
|
486
524
|
let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
|
|
487
525
|
function swapCanvas(newFilterClass) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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();
|
|
495
|
-
video.fb32 = newCanvas.fb32;
|
|
496
|
-
video.paint_ext = function paint(minx, miny, maxx, maxy) {
|
|
497
|
-
frames++;
|
|
498
|
-
if (frames < frameSkip) return;
|
|
499
|
-
frames = 0;
|
|
500
|
-
newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
|
|
501
|
-
};
|
|
502
|
-
canvas = newCanvas;
|
|
526
|
+
// Everything but the filter is the same whatever the mode: the framebuffer
|
|
527
|
+
// texture, the vertex buffers and fb32 all carry over untouched.
|
|
528
|
+
canvasLib.useBestFilter(canvas, newFilterClass);
|
|
529
|
+
reportAnyFallback(canvas, newFilterClass);
|
|
503
530
|
// Follow the filter we ended up with, not the one we asked for: everything
|
|
504
|
-
// downstream
|
|
505
|
-
//
|
|
506
|
-
displayModeFilter =
|
|
531
|
+
// downstream (the monitor picture, the canvas geometry, how large a drawing
|
|
532
|
+
// buffer to ask for) comes from its display config.
|
|
533
|
+
displayModeFilter = canvas.filterClass;
|
|
534
|
+
// Back to the mode's own size, undoing any scaling the last one asked for.
|
|
535
|
+
sizeCanvasFor(displayModeFilter);
|
|
507
536
|
// Nothing else will redraw: the mode is changed from a modal, which stops
|
|
508
537
|
// the emulator.
|
|
509
538
|
video.paint();
|
|
510
539
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
|
|
511
540
|
}
|
|
512
541
|
|
|
513
|
-
|
|
542
|
+
const canvas = createCanvasForFilter(displayModeFilter);
|
|
514
543
|
displayModeFilter = canvas.filterClass;
|
|
515
544
|
|
|
516
545
|
video = new Video(
|
|
@@ -628,14 +657,19 @@ pastetext.addEventListener("dragover", function (event) {
|
|
|
628
657
|
pastetext.addEventListener("drop", async function (event) {
|
|
629
658
|
utils.noteEvent("local", "drop");
|
|
630
659
|
const file = event.dataTransfer.files[0];
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
await
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
660
|
+
if (!file) return;
|
|
661
|
+
try {
|
|
662
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
663
|
+
if (isSnapshotFile(file.name, arrayBuffer)) {
|
|
664
|
+
await loadStateFromFile(file, arrayBuffer);
|
|
665
|
+
} else if (file.name.toLowerCase().endsWith(".uef")) {
|
|
666
|
+
// Regular UEF tape image (not a BeebEm save state)
|
|
667
|
+
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
|
|
668
|
+
} else {
|
|
669
|
+
await loadHTMLFile(file);
|
|
670
|
+
}
|
|
671
|
+
} catch (error) {
|
|
672
|
+
reportLoadFailure(file.name, error);
|
|
639
673
|
}
|
|
640
674
|
});
|
|
641
675
|
|
|
@@ -725,17 +759,14 @@ if (config.hasEconet) {
|
|
|
725
759
|
}
|
|
726
760
|
|
|
727
761
|
const cmos = new Cmos(
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
window.localStorage.cmosRam = JSON.stringify(data);
|
|
737
|
-
},
|
|
738
|
-
},
|
|
762
|
+
localStoragePersistence(
|
|
763
|
+
() => window.localStorage,
|
|
764
|
+
(error) =>
|
|
765
|
+
toast(
|
|
766
|
+
`Settings changed with *CONFIGURE will not be kept (${errorText(error)}). Check that this site is allowed to store data, and that its storage is not full.`,
|
|
767
|
+
{ title: "Settings", quietKey: "quietCmosSave" },
|
|
768
|
+
),
|
|
769
|
+
),
|
|
739
770
|
model.cmosOverride,
|
|
740
771
|
econet,
|
|
741
772
|
);
|
|
@@ -747,12 +778,17 @@ function checkPrinterWindow() {
|
|
|
747
778
|
if (printerWindow && !printerWindow.closed) return;
|
|
748
779
|
|
|
749
780
|
printerWindow = window.open("", "_blank", "height=300,width=400");
|
|
781
|
+
if (!printerWindow) {
|
|
782
|
+
toast("The printer output window was blocked. Allow pop-up windows for this site, then press Ctrl-B again.", {
|
|
783
|
+
title: "Printer",
|
|
784
|
+
});
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
750
787
|
printerWindow.document.write(
|
|
751
788
|
'<textarea id="text" rows="15" cols="40" placeholder="Printer outputs here..."></textarea>',
|
|
752
789
|
);
|
|
753
790
|
printerTextArea = printerWindow.document.getElementById("text");
|
|
754
|
-
|
|
755
|
-
processor.uservia.setca1(true);
|
|
791
|
+
printerTextArea.value = printer.text;
|
|
756
792
|
}
|
|
757
793
|
|
|
758
794
|
const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
|
|
@@ -768,6 +804,8 @@ processor = new CpuClass(model, {
|
|
|
768
804
|
econet,
|
|
769
805
|
});
|
|
770
806
|
|
|
807
|
+
printer.attach(processor.uservia);
|
|
808
|
+
|
|
771
809
|
processor.teletextAdaptor?.addEventListener("notice", showNotice);
|
|
772
810
|
|
|
773
811
|
// Create input sources
|
|
@@ -1178,13 +1216,13 @@ async function hfeClick(file) {
|
|
|
1178
1216
|
const name = describeHfe(file).title;
|
|
1179
1217
|
popupLoading("Loading " + name);
|
|
1180
1218
|
try {
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1219
|
+
const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
|
|
1220
|
+
putDiscIn(0, loaded);
|
|
1183
1221
|
loadingFinished();
|
|
1184
1222
|
if (needsAutoboot) autoboot(name);
|
|
1185
1223
|
} catch (err) {
|
|
1186
1224
|
console.error("Error loading disc image:", err);
|
|
1187
|
-
loadingFinished(err);
|
|
1225
|
+
loadingFinished(`Unable to load ${name} from the HFE archive: ${errorText(err)}`);
|
|
1188
1226
|
}
|
|
1189
1227
|
}
|
|
1190
1228
|
|
|
@@ -1194,13 +1232,15 @@ function hfeOnCat(catalogue) {
|
|
|
1194
1232
|
const list = document.getElementById("hfe-list");
|
|
1195
1233
|
document.querySelector("#hfe .loading").style.display = "none";
|
|
1196
1234
|
const template = list.querySelector(".template");
|
|
1235
|
+
showProvenanceChoices(catalogue, onHfeFilter);
|
|
1197
1236
|
|
|
1198
1237
|
const addSome = (remaining) => {
|
|
1199
1238
|
if (ticket !== hfeRender) return;
|
|
1200
1239
|
const MaxAtATime = 100;
|
|
1201
1240
|
const Delay = 30;
|
|
1202
|
-
// Read per batch:
|
|
1241
|
+
// Read per batch: both can be changed while this is still going.
|
|
1203
1242
|
const filter = document.getElementById("hfe-filter").value.toLowerCase();
|
|
1243
|
+
const shown = shownProvenances();
|
|
1204
1244
|
for (const file of remaining.slice(0, MaxAtATime)) {
|
|
1205
1245
|
const { title, publisher, detail } = describeHfe(file);
|
|
1206
1246
|
const row = template.cloneNode(true);
|
|
@@ -1208,6 +1248,8 @@ function hfeOnCat(catalogue) {
|
|
|
1208
1248
|
row.querySelector(".name").textContent = title;
|
|
1209
1249
|
row.querySelector(".publisher").textContent = publisher;
|
|
1210
1250
|
row.querySelector(".detail").textContent = detail;
|
|
1251
|
+
row.querySelector(".provenance").textContent =
|
|
1252
|
+
file.provenance === Provenance.Reconstructed ? "reconstructed" : "";
|
|
1211
1253
|
if (file.notes) row.title = file.notes;
|
|
1212
1254
|
// The row is an anchor, and letting it navigate to "#" would push a
|
|
1213
1255
|
// history entry of its own on top of the one updateUrl pushes.
|
|
@@ -1216,8 +1258,9 @@ function hfeOnCat(catalogue) {
|
|
|
1216
1258
|
hfeClick(file);
|
|
1217
1259
|
$hfeModal.hide();
|
|
1218
1260
|
});
|
|
1219
|
-
row.
|
|
1261
|
+
row.hfeFile = file;
|
|
1220
1262
|
list.appendChild(row);
|
|
1263
|
+
showHfeRow(row, file, filter, shown);
|
|
1221
1264
|
}
|
|
1222
1265
|
if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
|
|
1223
1266
|
};
|
|
@@ -1231,7 +1274,53 @@ document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
|
|
|
1231
1274
|
document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
|
|
1232
1275
|
|
|
1233
1276
|
const hfeFilter = document.getElementById("hfe-filter");
|
|
1234
|
-
const
|
|
1277
|
+
const hfeProvenance = document.getElementById("hfe-provenance");
|
|
1278
|
+
|
|
1279
|
+
const HfeProvenanceLabels = {
|
|
1280
|
+
[Provenance.Captured]: ["Captured", "Direct from disc"],
|
|
1281
|
+
[Provenance.Reconstructed]: ["Reconstructed", "Rebuilt from a sector dump"],
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
/** Which provenances the picker is showing, or null when it is not offering the choice. */
|
|
1285
|
+
const shownProvenances = () => {
|
|
1286
|
+
const boxes = [...hfeProvenance.querySelectorAll("input")];
|
|
1287
|
+
return boxes.length ? new Set(boxes.filter((box) => box.checked).map((box) => box.value)) : null;
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
// Offer one tick per provenance the archive actually holds, rather than naming
|
|
1291
|
+
// them here: a source added later should appear without this having to change.
|
|
1292
|
+
function showProvenanceChoices(catalogue, onChange) {
|
|
1293
|
+
const present = provenancesIn(catalogue);
|
|
1294
|
+
// Nothing to choose between: no ticks, and shownProvenances says "all".
|
|
1295
|
+
if (present.length < 2) {
|
|
1296
|
+
hfeProvenance.replaceChildren();
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const wasShown = shownProvenances();
|
|
1300
|
+
hfeProvenance.replaceChildren(
|
|
1301
|
+
...present.map((provenance) => {
|
|
1302
|
+
const [text, why] = HfeProvenanceLabels[provenance] ?? [provenance, ""];
|
|
1303
|
+
const label = document.createElement("label");
|
|
1304
|
+
label.title = why;
|
|
1305
|
+
const box = document.createElement("input");
|
|
1306
|
+
box.type = "checkbox";
|
|
1307
|
+
box.value = provenance;
|
|
1308
|
+
box.checked = !wasShown || wasShown.has(provenance);
|
|
1309
|
+
box.addEventListener("change", onChange);
|
|
1310
|
+
label.append(box, text);
|
|
1311
|
+
return label;
|
|
1312
|
+
}),
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const showHfeRow = (row, file, filter, shown) => (row.style.display = matches(file, filter, shown) ? "" : "none");
|
|
1317
|
+
|
|
1318
|
+
const onHfeFilter = () => {
|
|
1319
|
+
const filter = hfeFilter.value.toLowerCase();
|
|
1320
|
+
const shown = shownProvenances();
|
|
1321
|
+
for (const row of document.querySelectorAll("#hfe-list li:not(.template)"))
|
|
1322
|
+
showHfeRow(row, row.hfeFile, filter, shown);
|
|
1323
|
+
};
|
|
1235
1324
|
hfeFilter.addEventListener("change", onHfeFilter);
|
|
1236
1325
|
hfeFilter.addEventListener("keyup", onHfeFilter);
|
|
1237
1326
|
|
|
@@ -1352,7 +1441,12 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1352
1441
|
discImage = split.image;
|
|
1353
1442
|
const schema = split.schema;
|
|
1354
1443
|
if (schema[0] === "!" || schema === "local") {
|
|
1355
|
-
return disc.localDisc(processor.fdc, discImage, layout)
|
|
1444
|
+
return disc.localDisc(processor.fdc, discImage, layout, (error) =>
|
|
1445
|
+
toast(
|
|
1446
|
+
`Browser storage would not take changes to ${discImage} (${errorText(error)}). Use Discs, Download to keep a copy.`,
|
|
1447
|
+
{ title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
|
|
1448
|
+
),
|
|
1449
|
+
);
|
|
1356
1450
|
}
|
|
1357
1451
|
// TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
|
|
1358
1452
|
// Consider:
|
|
@@ -1363,12 +1457,13 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1363
1457
|
switch (schema) {
|
|
1364
1458
|
case "|":
|
|
1365
1459
|
case "sth": {
|
|
1366
|
-
const { name, data } = await discSth.fetch(discImage);
|
|
1460
|
+
const { name, data, ignored } = await discSth.fetch(discImage);
|
|
1461
|
+
reportIgnoredFiles(name, ignored);
|
|
1367
1462
|
return disc.discFor(processor.fdc, name, data, undefined, layout);
|
|
1368
1463
|
}
|
|
1369
1464
|
|
|
1370
1465
|
case "hfe":
|
|
1371
|
-
return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage));
|
|
1466
|
+
return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage), undefined, layout);
|
|
1372
1467
|
|
|
1373
1468
|
case "gd": {
|
|
1374
1469
|
const splat = discImage.match(/([^/]+)\/?(.*)/);
|
|
@@ -1384,7 +1479,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1384
1479
|
|
|
1385
1480
|
case "data": {
|
|
1386
1481
|
const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
|
|
1387
|
-
const { name, data } = await
|
|
1482
|
+
const { name, data } = await unzipAndReport(arr);
|
|
1388
1483
|
return disc.discFor(processor.fdc, name, data, undefined, layout);
|
|
1389
1484
|
}
|
|
1390
1485
|
case "http":
|
|
@@ -1395,7 +1490,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1395
1490
|
discImage = new URL(asUrl).pathname;
|
|
1396
1491
|
let discData = await utils.loadData(asUrl);
|
|
1397
1492
|
if (/\.zip/i.test(discImage)) {
|
|
1398
|
-
const unzipped = await
|
|
1493
|
+
const unzipped = await unzipAndReport(discData);
|
|
1399
1494
|
discData = unzipped.data;
|
|
1400
1495
|
discImage = unzipped.name;
|
|
1401
1496
|
}
|
|
@@ -1414,13 +1509,14 @@ async function loadTapeImage(tapeImage) {
|
|
|
1414
1509
|
switch (schema) {
|
|
1415
1510
|
case "|":
|
|
1416
1511
|
case "sth": {
|
|
1417
|
-
const { name, data } = await tapeSth.fetch(tapeImage);
|
|
1512
|
+
const { name, data, ignored } = await tapeSth.fetch(tapeImage);
|
|
1513
|
+
reportIgnoredFiles(name, ignored);
|
|
1418
1514
|
return await loadTapeFromData(name, data, model);
|
|
1419
1515
|
}
|
|
1420
1516
|
|
|
1421
1517
|
case "data": {
|
|
1422
1518
|
const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
|
|
1423
|
-
const { name, data } = await
|
|
1519
|
+
const { name, data } = await unzipAndReport(arr);
|
|
1424
1520
|
return await loadTapeFromData(name, data, model);
|
|
1425
1521
|
}
|
|
1426
1522
|
|
|
@@ -1432,7 +1528,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1432
1528
|
tapeImage = new URL(asUrl).pathname;
|
|
1433
1529
|
let tapeData = await utils.loadData(asUrl);
|
|
1434
1530
|
if (/\.zip/i.test(tapeImage)) {
|
|
1435
|
-
const unzipped = await
|
|
1531
|
+
const unzipped = await unzipAndReport(tapeData);
|
|
1436
1532
|
tapeData = unzipped.data;
|
|
1437
1533
|
tapeImage = unzipped.name;
|
|
1438
1534
|
}
|
|
@@ -1444,7 +1540,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1444
1540
|
let tapeData = await utils.loadData(tapePath);
|
|
1445
1541
|
let tapeName = tapeImage;
|
|
1446
1542
|
if (/\.zip/i.test(tapeName)) {
|
|
1447
|
-
const unzipped = await
|
|
1543
|
+
const unzipped = await unzipAndReport(tapeData);
|
|
1448
1544
|
tapeData = unzipped.data;
|
|
1449
1545
|
tapeName = unzipped.name;
|
|
1450
1546
|
}
|
|
@@ -1457,7 +1553,11 @@ document.getElementById("disc_load").addEventListener("change", async function (
|
|
|
1457
1553
|
if (evt.target.files.length === 0) return;
|
|
1458
1554
|
utils.noteEvent("local", "click"); // NB no filename here
|
|
1459
1555
|
const file = evt.target.files[0];
|
|
1460
|
-
|
|
1556
|
+
try {
|
|
1557
|
+
await loadHTMLFile(file);
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
reportLoadFailure(file.name, error);
|
|
1560
|
+
}
|
|
1461
1561
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1462
1562
|
});
|
|
1463
1563
|
|
|
@@ -1465,7 +1565,11 @@ document.getElementById("fs_load").addEventListener("change", async function (ev
|
|
|
1465
1565
|
if (evt.target.files.length === 0) return;
|
|
1466
1566
|
utils.noteEvent("local", "click"); // NB no filename here
|
|
1467
1567
|
const file = evt.target.files[0];
|
|
1468
|
-
|
|
1568
|
+
try {
|
|
1569
|
+
await loadSCSIFile(file);
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
reportLoadFailure(file.name, error);
|
|
1572
|
+
}
|
|
1469
1573
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1470
1574
|
});
|
|
1471
1575
|
|
|
@@ -1474,17 +1578,21 @@ document.getElementById("tape_load").addEventListener("change", async function (
|
|
|
1474
1578
|
const file = evt.target.files[0];
|
|
1475
1579
|
utils.noteEvent("local", "clickTape"); // NB no filename here
|
|
1476
1580
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1581
|
+
try {
|
|
1582
|
+
let tapeData = await readFileAsBinaryString(file);
|
|
1583
|
+
let tapeName = file.name;
|
|
1584
|
+
if (/\.zip/i.test(tapeName)) {
|
|
1585
|
+
const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
|
|
1586
|
+
tapeData = unzipped.data;
|
|
1587
|
+
tapeName = unzipped.name;
|
|
1588
|
+
}
|
|
1589
|
+
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
1590
|
+
delete parsedQuery.tape;
|
|
1591
|
+
updateUrl();
|
|
1592
|
+
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
reportLoadFailure(file.name, error);
|
|
1483
1595
|
}
|
|
1484
|
-
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
1485
|
-
delete parsedQuery.tape;
|
|
1486
|
-
updateUrl();
|
|
1487
|
-
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
1488
1596
|
|
|
1489
1597
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1490
1598
|
});
|
|
@@ -1578,10 +1686,14 @@ async function gdLoad(cat, layout) {
|
|
|
1578
1686
|
|
|
1579
1687
|
for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "none";
|
|
1580
1688
|
(async () => {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1689
|
+
try {
|
|
1690
|
+
const available = await googleDrive.initialise();
|
|
1691
|
+
if (available) {
|
|
1692
|
+
for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
|
|
1693
|
+
await gdAuth(true);
|
|
1694
|
+
}
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
console.log(`Google Drive is unavailable: ${errorText(error)}`);
|
|
1585
1697
|
}
|
|
1586
1698
|
})();
|
|
1587
1699
|
const googleDriveModal = new bootstrap.Modal(googleDriveEl);
|
|
@@ -1597,7 +1709,14 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
|
|
|
1597
1709
|
gdLoading.textContent = "Loading...";
|
|
1598
1710
|
gdLoading.style.display = "";
|
|
1599
1711
|
for (const el of googleDriveEl.querySelectorAll("li:not(.template)")) el.remove();
|
|
1600
|
-
|
|
1712
|
+
let cat;
|
|
1713
|
+
try {
|
|
1714
|
+
cat = await googleDrive.listFiles();
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
console.error("Error listing Google Drive files:", error);
|
|
1717
|
+
gdLoading.textContent = `Unable to list your Google Drive files: ${errorText(error)}`;
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1601
1720
|
const dbList = googleDriveEl.querySelector(".list");
|
|
1602
1721
|
gdLoading.style.display = "none";
|
|
1603
1722
|
const template = dbList.querySelector(".template");
|
|
@@ -1627,7 +1746,11 @@ for (const image of availableImages) {
|
|
|
1627
1746
|
utils.noteEvent("images", "click", image.file);
|
|
1628
1747
|
setDisc1Image(image.file);
|
|
1629
1748
|
$discsModal.hide();
|
|
1630
|
-
|
|
1749
|
+
try {
|
|
1750
|
+
putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
reportLoadFailure(`${image.name} (${image.file})`, error);
|
|
1753
|
+
}
|
|
1631
1754
|
});
|
|
1632
1755
|
}
|
|
1633
1756
|
|
|
@@ -1920,8 +2043,7 @@ const startPromise = (async () => {
|
|
|
1920
2043
|
try {
|
|
1921
2044
|
await load();
|
|
1922
2045
|
} catch (error) {
|
|
1923
|
-
|
|
1924
|
-
toast(`Could not load ${description}: ${error?.message ?? error}`, { title: "Loading" });
|
|
2046
|
+
reportLoadFailure(description, error);
|
|
1925
2047
|
}
|
|
1926
2048
|
})();
|
|
1927
2049
|
imageLoads.push(loading);
|
package/src/printer.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centronics printer attached to the user VIA: acknowledges each character and keeps the most
|
|
5
|
+
* recent output so it can be shown whenever a printer window is opened.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MaxBufferedChars = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export class Printer {
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} [handlers]
|
|
13
|
+
* @param {function(string): void} [handlers.onOutput] called with each character as it is printed
|
|
14
|
+
* @param {function(): void} [handlers.onFirstOutput] called once, when the machine first prints
|
|
15
|
+
*/
|
|
16
|
+
constructor({ onOutput = () => {}, onFirstOutput = () => {} } = {}) {
|
|
17
|
+
this._onOutput = onOutput;
|
|
18
|
+
this._onFirstOutput = onFirstOutput;
|
|
19
|
+
this._uservia = null;
|
|
20
|
+
this._olderText = "";
|
|
21
|
+
this._newerText = "";
|
|
22
|
+
this._hasPrinted = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
outputStrobe(level, output) {
|
|
26
|
+
if (!output || level) return;
|
|
27
|
+
|
|
28
|
+
const uservia = this._uservia;
|
|
29
|
+
// Ack the character by pulsing CA1 low.
|
|
30
|
+
uservia.setca1(false);
|
|
31
|
+
uservia.setca1(true);
|
|
32
|
+
this._append(String.fromCharCode(uservia.ora));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
attach(uservia) {
|
|
36
|
+
this._uservia = uservia;
|
|
37
|
+
// The ack line idles high, so that each ack is a falling edge.
|
|
38
|
+
uservia.setca1(true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The most recent output, at most MaxBufferedChars of it. */
|
|
42
|
+
get text() {
|
|
43
|
+
return (this._olderText + this._newerText).slice(-MaxBufferedChars);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_append(char) {
|
|
47
|
+
// Two chunks so the bound costs a trim when the text is read, not one per character.
|
|
48
|
+
this._newerText += char;
|
|
49
|
+
if (this._newerText.length >= MaxBufferedChars) {
|
|
50
|
+
this._olderText = this._newerText;
|
|
51
|
+
this._newerText = "";
|
|
52
|
+
}
|
|
53
|
+
if (!this._hasPrinted) {
|
|
54
|
+
this._hasPrinted = true;
|
|
55
|
+
this._onFirstOutput();
|
|
56
|
+
}
|
|
57
|
+
this._onOutput(char);
|
|
58
|
+
}
|
|
59
|
+
}
|