jsbeeb 1.17.1 → 1.19.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/package.json +4 -3
- package/src/6502.js +9 -1
- package/src/bbcdiscs.js +31 -1
- package/src/canvas.js +95 -37
- package/src/jsbeeb.css +23 -1
- package/src/machine-session.js +57 -0
- package/src/main.js +171 -69
- package/src/snapshot-helpers.js +0 -1
- package/src/teletext.js +21 -13
- package/src/teletext_adaptor.js +40 -5
- package/src/touchscreen.js +24 -3
- package/src/utils.js +19 -2
- package/src/via.js +25 -8
- package/src/web/audio-renderer.js +13 -8
- package/tests/test-machine.js +6 -8
package/src/main.js
CHANGED
|
@@ -12,7 +12,7 @@ import * as utils_atom from "./utils_atom.js";
|
|
|
12
12
|
import { LoadSD } from "./mmc.js";
|
|
13
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";
|
|
@@ -374,6 +374,10 @@ let tryGl = true;
|
|
|
374
374
|
if (parsedQuery.glEnabled !== undefined) {
|
|
375
375
|
tryGl = parsedQuery.glEnabled === "true";
|
|
376
376
|
}
|
|
377
|
+
let lowLatency = true;
|
|
378
|
+
if (parsedQuery.lowLatency !== undefined) {
|
|
379
|
+
lowLatency = parsedQuery.lowLatency === "true";
|
|
380
|
+
}
|
|
377
381
|
const screenCanvas = document.getElementById("screen");
|
|
378
382
|
|
|
379
383
|
const errorDialog = document.getElementById("error-dialog");
|
|
@@ -397,6 +401,24 @@ function showError(context, error) {
|
|
|
397
401
|
|
|
398
402
|
const errorText = (error) => error?.message ?? `${error}`;
|
|
399
403
|
|
|
404
|
+
function reportLoadFailure(description, error) {
|
|
405
|
+
console.error(`Error loading ${description}:`, error);
|
|
406
|
+
toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function reportIgnoredFiles(name, ignored) {
|
|
410
|
+
if (!ignored.length) return;
|
|
411
|
+
toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
|
|
412
|
+
title: "Archive",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function unzipAndReport(data) {
|
|
417
|
+
const unzipped = await utils.unzipDiscImage(data);
|
|
418
|
+
reportIgnoredFiles(unzipped.name, unzipped.ignored);
|
|
419
|
+
return unzipped;
|
|
420
|
+
}
|
|
421
|
+
|
|
400
422
|
function showNotice(event) {
|
|
401
423
|
const { message, title, quietKey } = event.detail;
|
|
402
424
|
toast(message, { title, quietKey });
|
|
@@ -471,59 +493,59 @@ function noteDriveTracks(driveIndex, discName) {
|
|
|
471
493
|
});
|
|
472
494
|
}
|
|
473
495
|
|
|
474
|
-
|
|
496
|
+
// Test which filter is actually in use, not merely whether we got WebGL: a
|
|
497
|
+
// filter can decline a context that works perfectly well for other modes, in
|
|
498
|
+
// which case we are quietly left with an unfiltered display.
|
|
499
|
+
function reportAnyFallback(displayCanvas, filterClass) {
|
|
500
|
+
if (displayCanvas.filterClass === filterClass) return;
|
|
501
|
+
const reason = displayCanvas.fallbackReason ? ` (${displayCanvas.fallbackReason})` : "";
|
|
502
|
+
const { name } = filterClass.getDisplayConfig();
|
|
503
|
+
toast(`${name} is not available on this device, so the standard display is in use${reason}.`, {
|
|
504
|
+
title: "Display",
|
|
505
|
+
quietKey: "quietDisplayFallback",
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function sizeCanvasFor(filterClass) {
|
|
475
510
|
// Not `config`: that is the emulator's live configuration object, declared
|
|
476
511
|
// at module scope and used throughout this file.
|
|
477
512
|
const displayConfig = filterClass.getDisplayConfig();
|
|
478
|
-
|
|
479
|
-
// creating the context, which fixes its initial viewport.
|
|
513
|
+
if (screenCanvas.width === displayConfig.canvasWidth && screenCanvas.height === displayConfig.canvasHeight) return;
|
|
480
514
|
screenCanvas.width = displayConfig.canvasWidth;
|
|
481
515
|
screenCanvas.height = displayConfig.canvasHeight;
|
|
516
|
+
}
|
|
482
517
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
// in which case bestCanvas quietly gives us an unfiltered GL canvas.
|
|
488
|
-
if (newCanvas.filterClass !== filterClass) {
|
|
489
|
-
const reason = newCanvas.fallbackReason ? ` (${newCanvas.fallbackReason})` : "";
|
|
490
|
-
toast(`${displayConfig.name} is not available on this device, so the standard display is in use${reason}.`, {
|
|
491
|
-
title: "Display",
|
|
492
|
-
quietKey: "quietDisplayFallback",
|
|
493
|
-
});
|
|
494
|
-
}
|
|
518
|
+
function createCanvasForFilter(filterClass) {
|
|
519
|
+
// Each mode says how many pixels it wants to draw into. Set this before
|
|
520
|
+
// creating the context, which fixes its initial viewport.
|
|
521
|
+
sizeCanvasFor(filterClass);
|
|
495
522
|
|
|
523
|
+
const newCanvas = tryGl
|
|
524
|
+
? canvasLib.bestCanvas(screenCanvas, filterClass, lowLatency)
|
|
525
|
+
: new canvasLib.Canvas(screenCanvas, lowLatency);
|
|
526
|
+
reportAnyFallback(newCanvas, filterClass);
|
|
496
527
|
return newCanvas;
|
|
497
528
|
}
|
|
498
529
|
|
|
499
530
|
let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
|
|
500
531
|
function swapCanvas(newFilterClass) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
// Only once the replacement exists, so a failure to build it leaves the
|
|
506
|
-
// display we already had. The two share a GL context but no GL objects.
|
|
507
|
-
oldCanvas.dispose();
|
|
508
|
-
video.fb32 = newCanvas.fb32;
|
|
509
|
-
video.paint_ext = function paint(minx, miny, maxx, maxy) {
|
|
510
|
-
frames++;
|
|
511
|
-
if (frames < frameSkip) return;
|
|
512
|
-
frames = 0;
|
|
513
|
-
newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
|
|
514
|
-
};
|
|
515
|
-
canvas = newCanvas;
|
|
532
|
+
// Everything but the filter is the same whatever the mode: the framebuffer
|
|
533
|
+
// texture, the vertex buffers and fb32 all carry over untouched.
|
|
534
|
+
canvasLib.useBestFilter(canvas, newFilterClass);
|
|
535
|
+
reportAnyFallback(canvas, newFilterClass);
|
|
516
536
|
// Follow the filter we ended up with, not the one we asked for: everything
|
|
517
|
-
// downstream
|
|
518
|
-
//
|
|
519
|
-
displayModeFilter =
|
|
537
|
+
// downstream (the monitor picture, the canvas geometry, how large a drawing
|
|
538
|
+
// buffer to ask for) comes from its display config.
|
|
539
|
+
displayModeFilter = canvas.filterClass;
|
|
540
|
+
// Back to the mode's own size, undoing any scaling the last one asked for.
|
|
541
|
+
sizeCanvasFor(displayModeFilter);
|
|
520
542
|
// Nothing else will redraw: the mode is changed from a modal, which stops
|
|
521
543
|
// the emulator.
|
|
522
544
|
video.paint();
|
|
523
545
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
|
|
524
546
|
}
|
|
525
547
|
|
|
526
|
-
|
|
548
|
+
const canvas = createCanvasForFilter(displayModeFilter);
|
|
527
549
|
displayModeFilter = canvas.filterClass;
|
|
528
550
|
|
|
529
551
|
video = new Video(
|
|
@@ -641,14 +663,19 @@ pastetext.addEventListener("dragover", function (event) {
|
|
|
641
663
|
pastetext.addEventListener("drop", async function (event) {
|
|
642
664
|
utils.noteEvent("local", "drop");
|
|
643
665
|
const file = event.dataTransfer.files[0];
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
await
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
666
|
+
if (!file) return;
|
|
667
|
+
try {
|
|
668
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
669
|
+
if (isSnapshotFile(file.name, arrayBuffer)) {
|
|
670
|
+
await loadStateFromFile(file, arrayBuffer);
|
|
671
|
+
} else if (file.name.toLowerCase().endsWith(".uef")) {
|
|
672
|
+
// Regular UEF tape image (not a BeebEm save state)
|
|
673
|
+
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
|
|
674
|
+
} else {
|
|
675
|
+
await loadHTMLFile(file);
|
|
676
|
+
}
|
|
677
|
+
} catch (error) {
|
|
678
|
+
reportLoadFailure(file.name, error);
|
|
652
679
|
}
|
|
653
680
|
});
|
|
654
681
|
|
|
@@ -1211,13 +1238,15 @@ function hfeOnCat(catalogue) {
|
|
|
1211
1238
|
const list = document.getElementById("hfe-list");
|
|
1212
1239
|
document.querySelector("#hfe .loading").style.display = "none";
|
|
1213
1240
|
const template = list.querySelector(".template");
|
|
1241
|
+
showProvenanceChoices(catalogue, onHfeFilter);
|
|
1214
1242
|
|
|
1215
1243
|
const addSome = (remaining) => {
|
|
1216
1244
|
if (ticket !== hfeRender) return;
|
|
1217
1245
|
const MaxAtATime = 100;
|
|
1218
1246
|
const Delay = 30;
|
|
1219
|
-
// Read per batch:
|
|
1247
|
+
// Read per batch: both can be changed while this is still going.
|
|
1220
1248
|
const filter = document.getElementById("hfe-filter").value.toLowerCase();
|
|
1249
|
+
const shown = shownProvenances();
|
|
1221
1250
|
for (const file of remaining.slice(0, MaxAtATime)) {
|
|
1222
1251
|
const { title, publisher, detail } = describeHfe(file);
|
|
1223
1252
|
const row = template.cloneNode(true);
|
|
@@ -1225,6 +1254,8 @@ function hfeOnCat(catalogue) {
|
|
|
1225
1254
|
row.querySelector(".name").textContent = title;
|
|
1226
1255
|
row.querySelector(".publisher").textContent = publisher;
|
|
1227
1256
|
row.querySelector(".detail").textContent = detail;
|
|
1257
|
+
row.querySelector(".provenance").textContent =
|
|
1258
|
+
file.provenance === Provenance.Reconstructed ? "reconstructed" : "";
|
|
1228
1259
|
if (file.notes) row.title = file.notes;
|
|
1229
1260
|
// The row is an anchor, and letting it navigate to "#" would push a
|
|
1230
1261
|
// history entry of its own on top of the one updateUrl pushes.
|
|
@@ -1233,8 +1264,9 @@ function hfeOnCat(catalogue) {
|
|
|
1233
1264
|
hfeClick(file);
|
|
1234
1265
|
$hfeModal.hide();
|
|
1235
1266
|
});
|
|
1236
|
-
row.
|
|
1267
|
+
row.hfeFile = file;
|
|
1237
1268
|
list.appendChild(row);
|
|
1269
|
+
showHfeRow(row, file, filter, shown);
|
|
1238
1270
|
}
|
|
1239
1271
|
if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
|
|
1240
1272
|
};
|
|
@@ -1248,7 +1280,53 @@ document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
|
|
|
1248
1280
|
document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
|
|
1249
1281
|
|
|
1250
1282
|
const hfeFilter = document.getElementById("hfe-filter");
|
|
1251
|
-
const
|
|
1283
|
+
const hfeProvenance = document.getElementById("hfe-provenance");
|
|
1284
|
+
|
|
1285
|
+
const HfeProvenanceLabels = {
|
|
1286
|
+
[Provenance.Captured]: ["Captured", "Direct from disc"],
|
|
1287
|
+
[Provenance.Reconstructed]: ["Reconstructed", "Rebuilt from a sector dump"],
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
/** Which provenances the picker is showing, or null when it is not offering the choice. */
|
|
1291
|
+
const shownProvenances = () => {
|
|
1292
|
+
const boxes = [...hfeProvenance.querySelectorAll("input")];
|
|
1293
|
+
return boxes.length ? new Set(boxes.filter((box) => box.checked).map((box) => box.value)) : null;
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
// Offer one tick per provenance the archive actually holds, rather than naming
|
|
1297
|
+
// them here: a source added later should appear without this having to change.
|
|
1298
|
+
function showProvenanceChoices(catalogue, onChange) {
|
|
1299
|
+
const present = provenancesIn(catalogue);
|
|
1300
|
+
// Nothing to choose between: no ticks, and shownProvenances says "all".
|
|
1301
|
+
if (present.length < 2) {
|
|
1302
|
+
hfeProvenance.replaceChildren();
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const wasShown = shownProvenances();
|
|
1306
|
+
hfeProvenance.replaceChildren(
|
|
1307
|
+
...present.map((provenance) => {
|
|
1308
|
+
const [text, why] = HfeProvenanceLabels[provenance] ?? [provenance, ""];
|
|
1309
|
+
const label = document.createElement("label");
|
|
1310
|
+
label.title = why;
|
|
1311
|
+
const box = document.createElement("input");
|
|
1312
|
+
box.type = "checkbox";
|
|
1313
|
+
box.value = provenance;
|
|
1314
|
+
box.checked = !wasShown || wasShown.has(provenance);
|
|
1315
|
+
box.addEventListener("change", onChange);
|
|
1316
|
+
label.append(box, text);
|
|
1317
|
+
return label;
|
|
1318
|
+
}),
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const showHfeRow = (row, file, filter, shown) => (row.style.display = matches(file, filter, shown) ? "" : "none");
|
|
1323
|
+
|
|
1324
|
+
const onHfeFilter = () => {
|
|
1325
|
+
const filter = hfeFilter.value.toLowerCase();
|
|
1326
|
+
const shown = shownProvenances();
|
|
1327
|
+
for (const row of document.querySelectorAll("#hfe-list li:not(.template)"))
|
|
1328
|
+
showHfeRow(row, row.hfeFile, filter, shown);
|
|
1329
|
+
};
|
|
1252
1330
|
hfeFilter.addEventListener("change", onHfeFilter);
|
|
1253
1331
|
hfeFilter.addEventListener("keyup", onHfeFilter);
|
|
1254
1332
|
|
|
@@ -1385,7 +1463,8 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1385
1463
|
switch (schema) {
|
|
1386
1464
|
case "|":
|
|
1387
1465
|
case "sth": {
|
|
1388
|
-
const { name, data } = await discSth.fetch(discImage);
|
|
1466
|
+
const { name, data, ignored } = await discSth.fetch(discImage);
|
|
1467
|
+
reportIgnoredFiles(name, ignored);
|
|
1389
1468
|
return disc.discFor(processor.fdc, name, data, undefined, layout);
|
|
1390
1469
|
}
|
|
1391
1470
|
|
|
@@ -1406,7 +1485,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1406
1485
|
|
|
1407
1486
|
case "data": {
|
|
1408
1487
|
const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
|
|
1409
|
-
const { name, data } = await
|
|
1488
|
+
const { name, data } = await unzipAndReport(arr);
|
|
1410
1489
|
return disc.discFor(processor.fdc, name, data, undefined, layout);
|
|
1411
1490
|
}
|
|
1412
1491
|
case "http":
|
|
@@ -1417,7 +1496,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1417
1496
|
discImage = new URL(asUrl).pathname;
|
|
1418
1497
|
let discData = await utils.loadData(asUrl);
|
|
1419
1498
|
if (/\.zip/i.test(discImage)) {
|
|
1420
|
-
const unzipped = await
|
|
1499
|
+
const unzipped = await unzipAndReport(discData);
|
|
1421
1500
|
discData = unzipped.data;
|
|
1422
1501
|
discImage = unzipped.name;
|
|
1423
1502
|
}
|
|
@@ -1436,13 +1515,14 @@ async function loadTapeImage(tapeImage) {
|
|
|
1436
1515
|
switch (schema) {
|
|
1437
1516
|
case "|":
|
|
1438
1517
|
case "sth": {
|
|
1439
|
-
const { name, data } = await tapeSth.fetch(tapeImage);
|
|
1518
|
+
const { name, data, ignored } = await tapeSth.fetch(tapeImage);
|
|
1519
|
+
reportIgnoredFiles(name, ignored);
|
|
1440
1520
|
return await loadTapeFromData(name, data, model);
|
|
1441
1521
|
}
|
|
1442
1522
|
|
|
1443
1523
|
case "data": {
|
|
1444
1524
|
const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
|
|
1445
|
-
const { name, data } = await
|
|
1525
|
+
const { name, data } = await unzipAndReport(arr);
|
|
1446
1526
|
return await loadTapeFromData(name, data, model);
|
|
1447
1527
|
}
|
|
1448
1528
|
|
|
@@ -1454,7 +1534,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1454
1534
|
tapeImage = new URL(asUrl).pathname;
|
|
1455
1535
|
let tapeData = await utils.loadData(asUrl);
|
|
1456
1536
|
if (/\.zip/i.test(tapeImage)) {
|
|
1457
|
-
const unzipped = await
|
|
1537
|
+
const unzipped = await unzipAndReport(tapeData);
|
|
1458
1538
|
tapeData = unzipped.data;
|
|
1459
1539
|
tapeImage = unzipped.name;
|
|
1460
1540
|
}
|
|
@@ -1466,7 +1546,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1466
1546
|
let tapeData = await utils.loadData(tapePath);
|
|
1467
1547
|
let tapeName = tapeImage;
|
|
1468
1548
|
if (/\.zip/i.test(tapeName)) {
|
|
1469
|
-
const unzipped = await
|
|
1549
|
+
const unzipped = await unzipAndReport(tapeData);
|
|
1470
1550
|
tapeData = unzipped.data;
|
|
1471
1551
|
tapeName = unzipped.name;
|
|
1472
1552
|
}
|
|
@@ -1479,7 +1559,11 @@ document.getElementById("disc_load").addEventListener("change", async function (
|
|
|
1479
1559
|
if (evt.target.files.length === 0) return;
|
|
1480
1560
|
utils.noteEvent("local", "click"); // NB no filename here
|
|
1481
1561
|
const file = evt.target.files[0];
|
|
1482
|
-
|
|
1562
|
+
try {
|
|
1563
|
+
await loadHTMLFile(file);
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
reportLoadFailure(file.name, error);
|
|
1566
|
+
}
|
|
1483
1567
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1484
1568
|
});
|
|
1485
1569
|
|
|
@@ -1487,7 +1571,11 @@ document.getElementById("fs_load").addEventListener("change", async function (ev
|
|
|
1487
1571
|
if (evt.target.files.length === 0) return;
|
|
1488
1572
|
utils.noteEvent("local", "click"); // NB no filename here
|
|
1489
1573
|
const file = evt.target.files[0];
|
|
1490
|
-
|
|
1574
|
+
try {
|
|
1575
|
+
await loadSCSIFile(file);
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
reportLoadFailure(file.name, error);
|
|
1578
|
+
}
|
|
1491
1579
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1492
1580
|
});
|
|
1493
1581
|
|
|
@@ -1496,17 +1584,21 @@ document.getElementById("tape_load").addEventListener("change", async function (
|
|
|
1496
1584
|
const file = evt.target.files[0];
|
|
1497
1585
|
utils.noteEvent("local", "clickTape"); // NB no filename here
|
|
1498
1586
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1587
|
+
try {
|
|
1588
|
+
let tapeData = await readFileAsBinaryString(file);
|
|
1589
|
+
let tapeName = file.name;
|
|
1590
|
+
if (/\.zip/i.test(tapeName)) {
|
|
1591
|
+
const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
|
|
1592
|
+
tapeData = unzipped.data;
|
|
1593
|
+
tapeName = unzipped.name;
|
|
1594
|
+
}
|
|
1595
|
+
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
1596
|
+
delete parsedQuery.tape;
|
|
1597
|
+
updateUrl();
|
|
1598
|
+
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
reportLoadFailure(file.name, error);
|
|
1505
1601
|
}
|
|
1506
|
-
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
1507
|
-
delete parsedQuery.tape;
|
|
1508
|
-
updateUrl();
|
|
1509
|
-
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
1510
1602
|
|
|
1511
1603
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1512
1604
|
});
|
|
@@ -1623,7 +1715,14 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
|
|
|
1623
1715
|
gdLoading.textContent = "Loading...";
|
|
1624
1716
|
gdLoading.style.display = "";
|
|
1625
1717
|
for (const el of googleDriveEl.querySelectorAll("li:not(.template)")) el.remove();
|
|
1626
|
-
|
|
1718
|
+
let cat;
|
|
1719
|
+
try {
|
|
1720
|
+
cat = await googleDrive.listFiles();
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
console.error("Error listing Google Drive files:", error);
|
|
1723
|
+
gdLoading.textContent = `Unable to list your Google Drive files: ${errorText(error)}`;
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1627
1726
|
const dbList = googleDriveEl.querySelector(".list");
|
|
1628
1727
|
gdLoading.style.display = "none";
|
|
1629
1728
|
const template = dbList.querySelector(".template");
|
|
@@ -1653,7 +1752,11 @@ for (const image of availableImages) {
|
|
|
1653
1752
|
utils.noteEvent("images", "click", image.file);
|
|
1654
1753
|
setDisc1Image(image.file);
|
|
1655
1754
|
$discsModal.hide();
|
|
1656
|
-
|
|
1755
|
+
try {
|
|
1756
|
+
putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
reportLoadFailure(`${image.name} (${image.file})`, error);
|
|
1759
|
+
}
|
|
1657
1760
|
});
|
|
1658
1761
|
}
|
|
1659
1762
|
|
|
@@ -1946,8 +2049,7 @@ const startPromise = (async () => {
|
|
|
1946
2049
|
try {
|
|
1947
2050
|
await load();
|
|
1948
2051
|
} catch (error) {
|
|
1949
|
-
|
|
1950
|
-
toast(`Could not load ${description}: ${error?.message ?? error}`, { title: "Loading" });
|
|
2052
|
+
reportLoadFailure(description, error);
|
|
1951
2053
|
}
|
|
1952
2054
|
})();
|
|
1953
2055
|
imageLoads.push(loading);
|
package/src/snapshot-helpers.js
CHANGED
package/src/teletext.js
CHANGED
|
@@ -11,7 +11,8 @@ export class Teletext {
|
|
|
11
11
|
this.sep = false;
|
|
12
12
|
this.dbl = this.oldDbl = this.secondHalfOfDouble = this.wasDbl = false;
|
|
13
13
|
this.gfx = false;
|
|
14
|
-
this.
|
|
14
|
+
this.conceal = false;
|
|
15
|
+
this.flash = false;
|
|
15
16
|
this.flashTime = 0;
|
|
16
17
|
this.heldChar = 0;
|
|
17
18
|
this.holdChar = false;
|
|
@@ -172,8 +173,8 @@ export class Teletext {
|
|
|
172
173
|
secondHalfOfDouble: this.secondHalfOfDouble,
|
|
173
174
|
wasDbl: this.wasDbl,
|
|
174
175
|
gfx: this.gfx,
|
|
176
|
+
conceal: this.conceal,
|
|
175
177
|
flash: this.flash,
|
|
176
|
-
flashOn: this.flashOn,
|
|
177
178
|
flashTime: this.flashTime,
|
|
178
179
|
heldChar: this.heldChar,
|
|
179
180
|
holdChar: this.holdChar,
|
|
@@ -198,8 +199,8 @@ export class Teletext {
|
|
|
198
199
|
this.secondHalfOfDouble = state.secondHalfOfDouble;
|
|
199
200
|
this.wasDbl = state.wasDbl;
|
|
200
201
|
this.gfx = state.gfx;
|
|
202
|
+
this.conceal = state.conceal ?? false;
|
|
201
203
|
this.flash = state.flash;
|
|
202
|
-
this.flashOn = state.flashOn;
|
|
203
204
|
this.flashTime = state.flashTime;
|
|
204
205
|
this.heldChar = state.heldChar;
|
|
205
206
|
this.holdChar = state.holdChar;
|
|
@@ -245,6 +246,7 @@ export class Teletext {
|
|
|
245
246
|
case 7:
|
|
246
247
|
this.gfx = false;
|
|
247
248
|
this.col = data;
|
|
249
|
+
this.conceal = false;
|
|
248
250
|
this.setNextChars();
|
|
249
251
|
break;
|
|
250
252
|
case 8:
|
|
@@ -267,10 +269,11 @@ export class Teletext {
|
|
|
267
269
|
case 23:
|
|
268
270
|
this.gfx = true;
|
|
269
271
|
this.col = data & 7;
|
|
272
|
+
this.conceal = false;
|
|
270
273
|
this.setNextChars();
|
|
271
274
|
break;
|
|
272
275
|
case 24:
|
|
273
|
-
this.
|
|
276
|
+
this.conceal = true;
|
|
274
277
|
break;
|
|
275
278
|
case 25:
|
|
276
279
|
this.sep = false;
|
|
@@ -295,7 +298,6 @@ export class Teletext {
|
|
|
295
298
|
}
|
|
296
299
|
if (wasGfx && (wasHoldChar || this.holdChar) && this.dbl === this.oldDbl) {
|
|
297
300
|
data = this.heldChar;
|
|
298
|
-
if (data >= 0x40 && data < 0x60) data = 0x20;
|
|
299
301
|
this.curGlyphs = this.heldGlyphs;
|
|
300
302
|
} else {
|
|
301
303
|
this.heldChar = 0x20;
|
|
@@ -328,13 +330,14 @@ export class Teletext {
|
|
|
328
330
|
|
|
329
331
|
// 3:1 flash ratio.
|
|
330
332
|
if (++this.flashTime === 64) this.flashTime = 0;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Flashing text starts off in sync with a slow cursor, extinguished together. Multiple MODE
|
|
336
|
+
// changes gradually desynchronise the frame counters.
|
|
337
|
+
// TODO: setDEW is reached a MOS-dependent number of times before Video.frameCount rises, so
|
|
338
|
+
// the initial sync here holds under MOS 1.20 only.
|
|
339
|
+
get hideFlashing() {
|
|
340
|
+
return this.flashTime < 16;
|
|
338
341
|
}
|
|
339
342
|
|
|
340
343
|
setDISPTMG(level) {
|
|
@@ -358,6 +361,7 @@ export class Teletext {
|
|
|
358
361
|
this.flash = false;
|
|
359
362
|
this.sep = false;
|
|
360
363
|
this.gfx = false;
|
|
364
|
+
this.conceal = false;
|
|
361
365
|
this.dbl = false;
|
|
362
366
|
|
|
363
367
|
this.scanlineCounter++;
|
|
@@ -396,6 +400,7 @@ export class Teletext {
|
|
|
396
400
|
this.curGlyphs = this.nextGlyphs;
|
|
397
401
|
|
|
398
402
|
let flashThisCell = this.flash;
|
|
403
|
+
let concealThisCell = this.conceal;
|
|
399
404
|
if (data < 0x20) {
|
|
400
405
|
data = this.handleControlCode(data);
|
|
401
406
|
} else if (this.gfx) {
|
|
@@ -419,7 +424,10 @@ export class Teletext {
|
|
|
419
424
|
// Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
|
|
420
425
|
if (flashThisCell && !this.flash) flashThisCell = false;
|
|
421
426
|
|
|
422
|
-
|
|
427
|
+
// Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
|
|
428
|
+
if (this.conceal) concealThisCell = true;
|
|
429
|
+
|
|
430
|
+
if (concealThisCell || (flashThisCell && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
|
|
423
431
|
const backgroundColour = this.colour[(this.bg & 7) << 5];
|
|
424
432
|
for (let i = 0; i < 16; ++i) {
|
|
425
433
|
buf[offset++] = backgroundColour;
|
package/src/teletext_adaptor.js
CHANGED
|
@@ -94,6 +94,45 @@ export class TeletextAdaptor extends EventTarget {
|
|
|
94
94
|
this.currentFrame = 0;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
updateIrq() {
|
|
98
|
+
if (this.teletextInts && this.teletextStatus & 0x80) {
|
|
99
|
+
this.cpu.interrupt |= 1 << TELETEXT_IRQ;
|
|
100
|
+
} else {
|
|
101
|
+
this.cpu.interrupt &= ~(1 << TELETEXT_IRQ);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
snapshotState() {
|
|
106
|
+
return {
|
|
107
|
+
teletextStatus: this.teletextStatus,
|
|
108
|
+
teletextInts: this.teletextInts,
|
|
109
|
+
teletextEnable: this.teletextEnable,
|
|
110
|
+
channel: this.channel,
|
|
111
|
+
currentFrame: this.currentFrame,
|
|
112
|
+
rowPtr: this.rowPtr,
|
|
113
|
+
colPtr: this.colPtr,
|
|
114
|
+
pollCount: this.pollCount,
|
|
115
|
+
frameBuffer: this.frameBuffer.map((row) => row.slice()),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
restoreState(state) {
|
|
120
|
+
this.teletextStatus = state.teletextStatus;
|
|
121
|
+
this.teletextInts = state.teletextInts;
|
|
122
|
+
this.teletextEnable = state.teletextEnable;
|
|
123
|
+
this.currentFrame = state.currentFrame;
|
|
124
|
+
this.rowPtr = state.rowPtr;
|
|
125
|
+
this.colPtr = state.colPtr;
|
|
126
|
+
this.pollCount = state.pollCount;
|
|
127
|
+
this.frameBuffer = state.frameBuffer.map((row) => row.slice());
|
|
128
|
+
this.updateIrq();
|
|
129
|
+
// Refetching the multi-megabyte stream on every restore would be ruinous for rewind.
|
|
130
|
+
if (this.channel !== state.channel) {
|
|
131
|
+
this.channel = state.channel;
|
|
132
|
+
this.loadChannelStream(this.channel);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
read(addr) {
|
|
98
137
|
let data = 0x00;
|
|
99
138
|
|
|
@@ -120,11 +159,7 @@ export class TeletextAdaptor extends EventTarget {
|
|
|
120
159
|
case 0x00:
|
|
121
160
|
// Status register
|
|
122
161
|
this.teletextInts = (value & 0x08) === 0x08;
|
|
123
|
-
|
|
124
|
-
this.cpu.interrupt |= 1 << TELETEXT_IRQ; // Interrupt if INT and interrupts enabled
|
|
125
|
-
} else {
|
|
126
|
-
this.cpu.interrupt &= ~(1 << TELETEXT_IRQ); // Clear interrupt
|
|
127
|
-
}
|
|
162
|
+
this.updateIrq();
|
|
128
163
|
this.teletextEnable = (value & 0x04) === 0x04;
|
|
129
164
|
if ((value & 0x03) !== this.channel && this.teletextEnable) {
|
|
130
165
|
this.channel = value & 0x03;
|
package/src/touchscreen.js
CHANGED
|
@@ -13,11 +13,32 @@ export class TouchScreen {
|
|
|
13
13
|
constructor(scheduler, cyclesPerSecond) {
|
|
14
14
|
this.scheduler = scheduler;
|
|
15
15
|
this.pollCycles = cyclesPerSecond / PollHz;
|
|
16
|
-
this.mouse = { x: 0, y: 0, button: 0 };
|
|
17
16
|
this.outBuffer = new utils.Fifo(16);
|
|
18
|
-
this.delay = 0;
|
|
19
|
-
this.mode = 0;
|
|
20
17
|
this.pollTask = this.scheduler.newTask(() => this.poll());
|
|
18
|
+
this.reset();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
reset() {
|
|
22
|
+
this.mouse = { x: 0, y: 0, button: 0 };
|
|
23
|
+
this.mode = 0;
|
|
24
|
+
this.outBuffer.clear();
|
|
25
|
+
this.pollTask.cancel();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
snapshotState() {
|
|
29
|
+
return {
|
|
30
|
+
mode: this.mode,
|
|
31
|
+
outBuffer: this.outBuffer.toArray(),
|
|
32
|
+
pollTaskOffset: this.pollTask.scheduled() ? this.pollTask.expireEpoch - this.scheduler.epoch : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
restoreState(state) {
|
|
37
|
+
this.mode = state.mode;
|
|
38
|
+
this.outBuffer.clear();
|
|
39
|
+
for (const byte of state.outBuffer) this.store(byte);
|
|
40
|
+
this.pollTask.cancel();
|
|
41
|
+
if (state.pollTaskOffset !== null) this.pollTask.schedule(state.pollTaskOffset);
|
|
21
42
|
}
|
|
22
43
|
|
|
23
44
|
tryReceive(rts) {
|