jsbeeb 1.5.0 → 1.7.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 +22 -2
- package/package.json +3 -2
- package/src/6502.js +119 -6
- package/src/6502.opcodes.js +21 -8
- package/src/acia.js +42 -0
- package/src/adc.js +22 -0
- package/src/app/app.js +29 -0
- package/src/app/electron.js +10 -1
- package/src/app/preload.js +1 -0
- package/src/bem-snapshot.js +681 -0
- package/src/disc-drive.js +33 -0
- package/src/disc.js +176 -22
- package/src/fdc.js +3 -1
- package/src/intel-fdc.js +65 -0
- package/src/jsbeeb.css +66 -0
- package/src/machine-session.js +91 -0
- package/src/main.js +231 -6
- package/src/rewind-thumbnail.js +118 -0
- package/src/rewind-ui.js +230 -0
- package/src/rewind.js +84 -0
- package/src/scheduler.js +28 -0
- package/src/snapshot.js +143 -0
- package/src/soundchip.js +40 -5
- package/src/state-utils.js +66 -0
- package/src/teletext.js +69 -0
- package/src/via.js +91 -0
- package/src/video.js +143 -0
- package/src/wd-fdc.js +99 -2
- package/tests/test-machine.js +134 -146
package/src/main.js
CHANGED
|
@@ -30,6 +30,10 @@ import { MicrophoneInput } from "./microphone-input.js";
|
|
|
30
30
|
import { SpeechOutput } from "./speech-output.js";
|
|
31
31
|
import { MouseJoystickSource } from "./mouse-joystick-source.js";
|
|
32
32
|
import { getFilterForMode } from "./canvas.js";
|
|
33
|
+
import { createSnapshot, restoreSnapshot, snapshotToJSON, snapshotFromJSON, modelsCompatible } from "./snapshot.js";
|
|
34
|
+
import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
|
|
35
|
+
import { RewindBuffer } from "./rewind.js";
|
|
36
|
+
import { RewindUI } from "./rewind-ui.js";
|
|
33
37
|
import {
|
|
34
38
|
buildUrlFromParams,
|
|
35
39
|
guessModelFromHostname,
|
|
@@ -42,6 +46,7 @@ import {
|
|
|
42
46
|
|
|
43
47
|
let processor;
|
|
44
48
|
let video;
|
|
49
|
+
let rewindUI;
|
|
45
50
|
const dbgr = new Debugger();
|
|
46
51
|
let frames = 0;
|
|
47
52
|
let frameSkip = 0;
|
|
@@ -327,6 +332,16 @@ const $screen = $("#screen");
|
|
|
327
332
|
const $errorDialog = $("#error-dialog");
|
|
328
333
|
const $errorDialogModal = new bootstrap.Modal($errorDialog[0]);
|
|
329
334
|
|
|
335
|
+
async function compressBlob(blob) {
|
|
336
|
+
const stream = blob.stream().pipeThrough(new CompressionStream("gzip"));
|
|
337
|
+
return new Response(stream).blob();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function decompressBlob(blob) {
|
|
341
|
+
const stream = blob.stream().pipeThrough(new DecompressionStream("gzip"));
|
|
342
|
+
return new Response(stream).blob();
|
|
343
|
+
}
|
|
344
|
+
|
|
330
345
|
function showError(context, error) {
|
|
331
346
|
$errorDialog.find(".context").text(context);
|
|
332
347
|
$errorDialog.find(".error").text(error);
|
|
@@ -437,8 +452,11 @@ function downloadDriveData(data, name, extension) {
|
|
|
437
452
|
}
|
|
438
453
|
|
|
439
454
|
async function loadHTMLFile(file) {
|
|
440
|
-
const
|
|
441
|
-
|
|
455
|
+
const imageData = utils.stringToUint8Array(await readFileAsBinaryString(file));
|
|
456
|
+
const loadedDisc = disc.discFor(processor.fdc, file.name, imageData);
|
|
457
|
+
// Local file: retain the image bytes for embedding in save-to-file snapshots.
|
|
458
|
+
loadedDisc.setOriginalImage(imageData);
|
|
459
|
+
processor.fdc.loadDisc(0, loadedDisc);
|
|
442
460
|
delete parsedQuery.disc;
|
|
443
461
|
delete parsedQuery.disc1;
|
|
444
462
|
updateUrl();
|
|
@@ -474,7 +492,11 @@ $pastetext.on("dragover", function (event) {
|
|
|
474
492
|
$pastetext.on("drop", async function (event) {
|
|
475
493
|
utils.noteEvent("local", "drop");
|
|
476
494
|
const file = event.originalEvent.dataTransfer.files[0];
|
|
477
|
-
|
|
495
|
+
if (isSnapshotFile(file.name)) {
|
|
496
|
+
await loadStateFromFile(file);
|
|
497
|
+
} else {
|
|
498
|
+
await loadHTMLFile(file);
|
|
499
|
+
}
|
|
478
500
|
});
|
|
479
501
|
|
|
480
502
|
const $cub = $("#cub-monitor");
|
|
@@ -774,6 +796,17 @@ keyboard.registerKeyHandler(
|
|
|
774
796
|
{ alt: false, ctrl: true },
|
|
775
797
|
);
|
|
776
798
|
|
|
799
|
+
keyboard.registerKeyHandler(
|
|
800
|
+
utils.keyCodes.PAGEDOWN,
|
|
801
|
+
(down) => {
|
|
802
|
+
if (down) {
|
|
803
|
+
utils.noteEvent("keyboard", "press", "pagedown");
|
|
804
|
+
if (rewindUI) rewindUI.open();
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
{ alt: true, ctrl: false },
|
|
808
|
+
);
|
|
809
|
+
|
|
777
810
|
keyboard.registerKeyHandler(
|
|
778
811
|
utils.keyCodes.B,
|
|
779
812
|
(down) => {
|
|
@@ -824,6 +857,12 @@ function setDisc1Image(name) {
|
|
|
824
857
|
config.emit("media-changed", { disc1: name });
|
|
825
858
|
}
|
|
826
859
|
|
|
860
|
+
function setDisc2Image(name) {
|
|
861
|
+
parsedQuery.disc2 = name;
|
|
862
|
+
updateUrl();
|
|
863
|
+
config.emit("media-changed", { disc2: name });
|
|
864
|
+
}
|
|
865
|
+
|
|
827
866
|
function setTapeImage(name) {
|
|
828
867
|
parsedQuery.tape = name;
|
|
829
868
|
updateUrl();
|
|
@@ -1019,6 +1058,51 @@ function splitImage(image) {
|
|
|
1019
1058
|
return { image: image, schema: schema };
|
|
1020
1059
|
}
|
|
1021
1060
|
|
|
1061
|
+
async function reloadSnapshotMedia(media) {
|
|
1062
|
+
if (!media) return;
|
|
1063
|
+
for (let driveIndex = 0; driveIndex < 2; driveIndex++) {
|
|
1064
|
+
const discKey = driveIndex === 0 ? "disc1" : "disc2";
|
|
1065
|
+
const imageDataKey = discKey + "ImageData";
|
|
1066
|
+
const crcKey = discKey + "Crc32";
|
|
1067
|
+
|
|
1068
|
+
let loadedDisc = null;
|
|
1069
|
+
if (media[discKey]) {
|
|
1070
|
+
// URL-based disc — reload from source
|
|
1071
|
+
loadedDisc = await loadDiscImage(media[discKey]);
|
|
1072
|
+
} else if (media[imageDataKey]) {
|
|
1073
|
+
// Locally-loaded disc — reconstruct from embedded image data
|
|
1074
|
+
const imageData =
|
|
1075
|
+
media[imageDataKey] instanceof Uint8Array
|
|
1076
|
+
? media[imageDataKey]
|
|
1077
|
+
: new Uint8Array(Object.values(media[imageDataKey]));
|
|
1078
|
+
const discName = media[discKey + "Name"] || "snapshot.ssd";
|
|
1079
|
+
loadedDisc = disc.discFor(processor.fdc, discName, imageData);
|
|
1080
|
+
// Retain the image bytes so subsequent saves can re-embed them.
|
|
1081
|
+
loadedDisc.setOriginalImage(imageData);
|
|
1082
|
+
}
|
|
1083
|
+
if (!loadedDisc) continue;
|
|
1084
|
+
|
|
1085
|
+
// Verify CRC32 if present
|
|
1086
|
+
if (media[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
|
|
1087
|
+
if (loadedDisc.originalImageCrc32 !== media[crcKey]) {
|
|
1088
|
+
showError(
|
|
1089
|
+
"loading state",
|
|
1090
|
+
"The disc image appears to have changed since this snapshot was saved. The restored state may not work correctly.",
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
processor.fdc.loadDisc(driveIndex, loadedDisc);
|
|
1096
|
+
// Only update the URL/query for URL-sourced discs. For embedded
|
|
1097
|
+
// (local-file) discs, setting parsedQuery would put a bogus source
|
|
1098
|
+
// in the URL and break subsequent saves/reloads.
|
|
1099
|
+
if (media[discKey]) {
|
|
1100
|
+
if (driveIndex === 0) setDisc1Image(media[discKey]);
|
|
1101
|
+
else setDisc2Image(media[discKey]);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1022
1106
|
async function loadDiscImage(discImage) {
|
|
1023
1107
|
if (!discImage) return null;
|
|
1024
1108
|
const split = splitImage(discImage);
|
|
@@ -1344,8 +1428,17 @@ $("#download-filestore-link").on("click", function () {
|
|
|
1344
1428
|
downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
|
|
1345
1429
|
});
|
|
1346
1430
|
|
|
1347
|
-
|
|
1431
|
+
function hardReset() {
|
|
1432
|
+
if (rewindUI) {
|
|
1433
|
+
rewindUI.close();
|
|
1434
|
+
rewindBuffer.clear();
|
|
1435
|
+
rewindUI.updateButtonState();
|
|
1436
|
+
}
|
|
1348
1437
|
processor.reset(true);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
$("#hard-reset").click(function (event) {
|
|
1441
|
+
hardReset();
|
|
1349
1442
|
event.preventDefault();
|
|
1350
1443
|
});
|
|
1351
1444
|
|
|
@@ -1354,6 +1447,98 @@ $("#soft-reset").click(function (event) {
|
|
|
1354
1447
|
event.preventDefault();
|
|
1355
1448
|
});
|
|
1356
1449
|
|
|
1450
|
+
$("#save-state").click(async function (event) {
|
|
1451
|
+
event.preventDefault();
|
|
1452
|
+
const wasRunning = running;
|
|
1453
|
+
if (running) stop(false);
|
|
1454
|
+
try {
|
|
1455
|
+
const media = {};
|
|
1456
|
+
if (parsedQuery.disc1 || parsedQuery.disc) media.disc1 = parsedQuery.disc1 || parsedQuery.disc;
|
|
1457
|
+
if (parsedQuery.disc2) media.disc2 = parsedQuery.disc2;
|
|
1458
|
+
|
|
1459
|
+
// For each drive with a disc loaded, include CRC32 for verification
|
|
1460
|
+
// and embed original image data if no URL source exists (local file).
|
|
1461
|
+
const drives = processor.fdc.drives;
|
|
1462
|
+
for (let driveIndex = 0; driveIndex < 2; driveIndex++) {
|
|
1463
|
+
const driveDisc = drives[driveIndex].disc;
|
|
1464
|
+
if (!driveDisc || driveDisc.originalImageCrc32 == null) continue;
|
|
1465
|
+
const discKey = driveIndex === 0 ? "disc1" : "disc2";
|
|
1466
|
+
const crcKey = discKey + "Crc32";
|
|
1467
|
+
media[crcKey] = driveDisc.originalImageCrc32;
|
|
1468
|
+
if (!media[discKey] && driveDisc.originalImageData) {
|
|
1469
|
+
media[discKey + "ImageData"] = driveDisc.originalImageData;
|
|
1470
|
+
media[discKey + "Name"] = driveDisc.name;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const snapshot = createSnapshot(processor, model, Object.keys(media).length > 0 ? media : undefined);
|
|
1475
|
+
const json = snapshotToJSON(snapshot);
|
|
1476
|
+
const blob = await compressBlob(new Blob([json]));
|
|
1477
|
+
const url = URL.createObjectURL(blob);
|
|
1478
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1479
|
+
const a = document.createElement("a");
|
|
1480
|
+
a.href = url;
|
|
1481
|
+
a.download = `jsbeeb-${model.name}-${timestamp}.json.gz`;
|
|
1482
|
+
a.click();
|
|
1483
|
+
URL.revokeObjectURL(url);
|
|
1484
|
+
} catch (e) {
|
|
1485
|
+
showError("saving state", e);
|
|
1486
|
+
}
|
|
1487
|
+
if (wasRunning) go();
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
async function loadStateFromFile(file) {
|
|
1491
|
+
const wasRunning = running;
|
|
1492
|
+
if (running) stop(false);
|
|
1493
|
+
try {
|
|
1494
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
1495
|
+
let snapshot;
|
|
1496
|
+
if (isBemSnapshot(arrayBuffer)) {
|
|
1497
|
+
snapshot = await parseBemSnapshot(arrayBuffer);
|
|
1498
|
+
} else {
|
|
1499
|
+
// Detect gzip (magic bytes 0x1f 0x8b) or plain JSON
|
|
1500
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
1501
|
+
let text;
|
|
1502
|
+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
|
1503
|
+
const decompressed = await decompressBlob(new Blob([arrayBuffer]));
|
|
1504
|
+
text = await decompressed.text();
|
|
1505
|
+
} else {
|
|
1506
|
+
text = new TextDecoder().decode(arrayBuffer);
|
|
1507
|
+
}
|
|
1508
|
+
snapshot = snapshotFromJSON(text);
|
|
1509
|
+
}
|
|
1510
|
+
if (!modelsCompatible(snapshot.model, model.name)) {
|
|
1511
|
+
// Model mismatch: stash state and reload with correct model
|
|
1512
|
+
sessionStorage.setItem("jsbeeb-pending-state", snapshotToJSON(snapshot));
|
|
1513
|
+
const newQuery = { ...parsedQuery, model: snapshot.model };
|
|
1514
|
+
const baseUrl = window.location.origin + window.location.pathname;
|
|
1515
|
+
window.location.href = buildUrlFromParams(baseUrl, newQuery, paramTypes);
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
// Order matters: reload disc media first so the base disc is in the
|
|
1519
|
+
// drive before restoreSnapshot applies dirty track overlays on top.
|
|
1520
|
+
await reloadSnapshotMedia(snapshot.media);
|
|
1521
|
+
restoreSnapshot(processor, model, snapshot);
|
|
1522
|
+
// Force a repaint so the display updates even while paused
|
|
1523
|
+
video.paint();
|
|
1524
|
+
} catch (e) {
|
|
1525
|
+
showError("loading state", e);
|
|
1526
|
+
}
|
|
1527
|
+
if (wasRunning) go();
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function isSnapshotFile(filename) {
|
|
1531
|
+
const lower = filename.toLowerCase();
|
|
1532
|
+
return lower.endsWith(".snp") || lower.endsWith(".json") || lower.endsWith(".json.gz") || lower.endsWith(".gz");
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
$("#load-state").on("change", async function (event) {
|
|
1536
|
+
const file = event.target.files[0];
|
|
1537
|
+
if (!file) return;
|
|
1538
|
+
event.target.value = "";
|
|
1539
|
+
await loadStateFromFile(file);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1357
1542
|
$("#tape-menu a").on("click", function (e) {
|
|
1358
1543
|
const type = $(e.target).attr("data-id");
|
|
1359
1544
|
if (type === undefined) return;
|
|
@@ -1485,7 +1670,7 @@ const startPromise = (async () => {
|
|
|
1485
1670
|
})();
|
|
1486
1671
|
|
|
1487
1672
|
startPromise
|
|
1488
|
-
.then(() => {
|
|
1673
|
+
.then(async () => {
|
|
1489
1674
|
switch (needsAutoboot) {
|
|
1490
1675
|
case "boot":
|
|
1491
1676
|
$sthAutoboot.prop("checked", true);
|
|
@@ -1509,6 +1694,22 @@ startPromise
|
|
|
1509
1694
|
dbgr.setPatch(parsedQuery.patch);
|
|
1510
1695
|
}
|
|
1511
1696
|
|
|
1697
|
+
// Restore pending state from a cross-model load (sessionStorage)
|
|
1698
|
+
const pendingState = sessionStorage.getItem("jsbeeb-pending-state");
|
|
1699
|
+
if (pendingState) {
|
|
1700
|
+
sessionStorage.removeItem("jsbeeb-pending-state");
|
|
1701
|
+
try {
|
|
1702
|
+
const snapshot = snapshotFromJSON(pendingState);
|
|
1703
|
+
// Order matters: reload disc media first so the base disc is in the
|
|
1704
|
+
// drive before restoreSnapshot applies dirty track overlays on top.
|
|
1705
|
+
await reloadSnapshotMedia(snapshot.media);
|
|
1706
|
+
restoreSnapshot(processor, model, snapshot);
|
|
1707
|
+
processor.execute(40000);
|
|
1708
|
+
} catch (e) {
|
|
1709
|
+
showError("restoring saved state", e);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1512
1713
|
go();
|
|
1513
1714
|
})
|
|
1514
1715
|
.catch((error) => {
|
|
@@ -1603,6 +1804,21 @@ function VirtualSpeedUpdater() {
|
|
|
1603
1804
|
|
|
1604
1805
|
const virtualSpeedUpdater = new VirtualSpeedUpdater();
|
|
1605
1806
|
|
|
1807
|
+
const rewindBuffer = new RewindBuffer(30);
|
|
1808
|
+
let rewindFrameCounter = 0;
|
|
1809
|
+
const RewindCaptureInterval = 50; // ~1 second at 50fps
|
|
1810
|
+
|
|
1811
|
+
rewindUI = new RewindUI({
|
|
1812
|
+
rewindBuffer,
|
|
1813
|
+
processor,
|
|
1814
|
+
video,
|
|
1815
|
+
captureInterval: RewindCaptureInterval,
|
|
1816
|
+
stop,
|
|
1817
|
+
go,
|
|
1818
|
+
isRunning: () => running,
|
|
1819
|
+
});
|
|
1820
|
+
rewindUI.updateButtonState();
|
|
1821
|
+
|
|
1606
1822
|
function draw(now) {
|
|
1607
1823
|
if (!running) {
|
|
1608
1824
|
last = 0;
|
|
@@ -1655,6 +1871,12 @@ function draw(now) {
|
|
|
1655
1871
|
}
|
|
1656
1872
|
const end = performance.now();
|
|
1657
1873
|
virtualSpeedUpdater.update(cycles, end - now, speedy);
|
|
1874
|
+
// Capture rewind snapshot periodically
|
|
1875
|
+
if (++rewindFrameCounter >= RewindCaptureInterval) {
|
|
1876
|
+
rewindFrameCounter = 0;
|
|
1877
|
+
rewindBuffer.push(processor.snapshotState());
|
|
1878
|
+
rewindUI.updateButtonState();
|
|
1879
|
+
}
|
|
1658
1880
|
} catch (e) {
|
|
1659
1881
|
running = false;
|
|
1660
1882
|
utils.noteEvent("exception", "thrown", e.stack);
|
|
@@ -1836,9 +2058,12 @@ electron({
|
|
|
1836
2058
|
}
|
|
1837
2059
|
},
|
|
1838
2060
|
},
|
|
2061
|
+
loadStateFile: loadStateFromFile,
|
|
1839
2062
|
actions: {
|
|
1840
2063
|
"soft-reset": () => processor.reset(false),
|
|
1841
|
-
"hard-reset":
|
|
2064
|
+
"hard-reset": hardReset,
|
|
2065
|
+
"save-state": () => $("#save-state").trigger("click"),
|
|
2066
|
+
rewind: () => rewindUI.open(),
|
|
1842
2067
|
},
|
|
1843
2068
|
});
|
|
1844
2069
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const ThumbnailWidth = 160;
|
|
4
|
+
const ThumbnailHeight = 128;
|
|
5
|
+
const FramebufferWidth = 1024;
|
|
6
|
+
const FramebufferHeight = 625;
|
|
7
|
+
const VisiblePixels = FramebufferWidth * FramebufferHeight;
|
|
8
|
+
const CyclesPerChunk = 8000;
|
|
9
|
+
// Safety limit to prevent infinite loops if video state is broken
|
|
10
|
+
const MaxChunks = 100;
|
|
11
|
+
|
|
12
|
+
// Reusable offscreen canvas and ImageData for captureThumbnail to avoid
|
|
13
|
+
// allocating a full 1024x625 buffer per thumbnail.
|
|
14
|
+
let srcCanvas = null;
|
|
15
|
+
let srcCtx = null;
|
|
16
|
+
let srcImageData = null;
|
|
17
|
+
|
|
18
|
+
function ensureSrcCanvas() {
|
|
19
|
+
if (srcCanvas) return;
|
|
20
|
+
srcCanvas = document.createElement("canvas");
|
|
21
|
+
srcCanvas.width = FramebufferWidth;
|
|
22
|
+
srcCanvas.height = FramebufferHeight;
|
|
23
|
+
srcCtx = srcCanvas.getContext("2d", { alpha: false });
|
|
24
|
+
srcImageData = srcCtx.createImageData(FramebufferWidth, FramebufferHeight);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render a single thumbnail canvas from a framebuffer Uint32Array.
|
|
29
|
+
* Reuses a shared offscreen canvas for the full-size copy, then
|
|
30
|
+
* downscales into a new small canvas for the thumbnail.
|
|
31
|
+
* @param {Uint32Array} fb32 - the framebuffer to capture
|
|
32
|
+
* @returns {HTMLCanvasElement} a small canvas with the downscaled image
|
|
33
|
+
*/
|
|
34
|
+
function captureThumbnail(fb32) {
|
|
35
|
+
ensureSrcCanvas();
|
|
36
|
+
// fb32 may be 1024x1024 (WebGL) or 1024x625 (2D) — only copy the visible region
|
|
37
|
+
new Uint32Array(srcImageData.data.buffer).set(fb32.subarray(0, VisiblePixels));
|
|
38
|
+
srcCtx.putImageData(srcImageData, 0, 0);
|
|
39
|
+
|
|
40
|
+
const thumb = document.createElement("canvas");
|
|
41
|
+
thumb.width = ThumbnailWidth;
|
|
42
|
+
thumb.height = ThumbnailHeight;
|
|
43
|
+
const thumbCtx = thumb.getContext("2d", { alpha: false });
|
|
44
|
+
thumbCtx.drawImage(srcCanvas, 0, 0, FramebufferWidth, FramebufferHeight, 0, 0, ThumbnailWidth, ThumbnailHeight);
|
|
45
|
+
return thumb;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Execute cycles until a complete top-to-bottom frame is in fb32.
|
|
50
|
+
*
|
|
51
|
+
* Snapshots may be mid-frame, so we run through two vsyncs:
|
|
52
|
+
* 1. First vsync completes the partial frame and clears fb32 normally.
|
|
53
|
+
* 2. Second vsync rasterises a full frame; we suppress clearPaintBuffer
|
|
54
|
+
* so fb32 retains the completed frame for capture.
|
|
55
|
+
*/
|
|
56
|
+
function executeUntilFrame(processor, video) {
|
|
57
|
+
const startFrame = video.frameCount;
|
|
58
|
+
|
|
59
|
+
// Phase 1: run to first vsync (completes partial frame, clears fb32)
|
|
60
|
+
for (let i = 0; i < MaxChunks; i++) {
|
|
61
|
+
if (processor.execute(CyclesPerChunk) === false) break;
|
|
62
|
+
if (video.frameCount !== startFrame) break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Phase 2: run to second vsync with clear suppressed (full frame in fb32)
|
|
66
|
+
const secondFrame = video.frameCount;
|
|
67
|
+
const origClear = video.clearPaintBuffer;
|
|
68
|
+
video.clearPaintBuffer = function () {};
|
|
69
|
+
try {
|
|
70
|
+
for (let i = 0; i < MaxChunks; i++) {
|
|
71
|
+
if (processor.execute(CyclesPerChunk) === false) return;
|
|
72
|
+
if (video.frameCount !== secondFrame) return;
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
video.clearPaintBuffer = origClear;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generate thumbnail canvases for all snapshots in a rewind buffer.
|
|
81
|
+
*
|
|
82
|
+
* While the emulator is paused:
|
|
83
|
+
* 1. Saves the current state
|
|
84
|
+
* 2. For each snapshot (oldest first), restores it, runs until a full
|
|
85
|
+
* frame has been rasterised, and captures a downscaled thumbnail
|
|
86
|
+
* 3. Restores the original state
|
|
87
|
+
*
|
|
88
|
+
* @param {object} processor - the Cpu6502 instance
|
|
89
|
+
* @param {object[]} snapshots - array of snapshots (oldest first)
|
|
90
|
+
* @param {object} video - the Video instance (used to access fb32)
|
|
91
|
+
* @param {number} captureInterval - rewind capture interval in frames (~50)
|
|
92
|
+
* @param {object} [savedState] - pre-saved state to restore after rendering (avoids double snapshot)
|
|
93
|
+
* @returns {{canvas: HTMLCanvasElement, index: number, ageSeconds: number}[]}
|
|
94
|
+
*/
|
|
95
|
+
export function renderThumbnails(processor, snapshots, video, captureInterval, savedState) {
|
|
96
|
+
if (snapshots.length === 0) return [];
|
|
97
|
+
|
|
98
|
+
if (!savedState) savedState = processor.snapshotState();
|
|
99
|
+
const framesPerSecond = 50;
|
|
100
|
+
const results = [];
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
for (let i = 0; i < snapshots.length; i++) {
|
|
104
|
+
processor.restoreState(snapshots[i]);
|
|
105
|
+
executeUntilFrame(processor, video);
|
|
106
|
+
const canvas = captureThumbnail(video.fb32);
|
|
107
|
+
const stepsFromNewest = snapshots.length - 1 - i;
|
|
108
|
+
const ageSeconds = Math.round((stepsFromNewest * captureInterval) / framesPerSecond);
|
|
109
|
+
results.push({ canvas, index: i, ageSeconds });
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
processor.restoreState(savedState);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { executeUntilFrame };
|
package/src/rewind-ui.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { renderThumbnails, executeUntilFrame } from "./rewind-thumbnail.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rewind scrubber UI — a filmstrip overlay showing thumbnails of recent
|
|
7
|
+
* emulator states, allowing the user to click to restore any of them.
|
|
8
|
+
*/
|
|
9
|
+
export class RewindUI {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {object} options.rewindBuffer - RewindBuffer instance
|
|
13
|
+
* @param {object} options.processor - Cpu6502 instance
|
|
14
|
+
* @param {object} options.video - Video instance
|
|
15
|
+
* @param {number} options.captureInterval - rewind capture interval in frames
|
|
16
|
+
* @param {function} options.stop - function to pause the emulator
|
|
17
|
+
* @param {function} options.go - function to resume the emulator
|
|
18
|
+
* @param {function} options.isRunning - function returning current running state
|
|
19
|
+
*/
|
|
20
|
+
constructor({ rewindBuffer, processor, video, captureInterval, stop, go, isRunning }) {
|
|
21
|
+
this.rewindBuffer = rewindBuffer;
|
|
22
|
+
this.processor = processor;
|
|
23
|
+
this.video = video;
|
|
24
|
+
this.captureInterval = captureInterval;
|
|
25
|
+
this.stop = stop;
|
|
26
|
+
this.go = go;
|
|
27
|
+
this.isRunning = isRunning;
|
|
28
|
+
|
|
29
|
+
this.panel = document.getElementById("rewind-panel");
|
|
30
|
+
this.filmstrip = document.getElementById("rewind-filmstrip");
|
|
31
|
+
this.closeBtn = document.getElementById("rewind-close");
|
|
32
|
+
this.openBtn = document.getElementById("rewind-open");
|
|
33
|
+
|
|
34
|
+
this.isOpen = false;
|
|
35
|
+
this.wasRunning = false;
|
|
36
|
+
this.selectedIndex = -1;
|
|
37
|
+
this.snapshots = [];
|
|
38
|
+
this.savedState = null;
|
|
39
|
+
|
|
40
|
+
this._onKeyDown = this._onKeyDown.bind(this);
|
|
41
|
+
this.closeBtn.addEventListener("click", () => this.close());
|
|
42
|
+
this.openBtn.addEventListener("click", (e) => {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
this.open();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Open the rewind scrubber panel. */
|
|
49
|
+
open() {
|
|
50
|
+
if (this.isOpen) return;
|
|
51
|
+
|
|
52
|
+
this.snapshots = this.rewindBuffer.getAll();
|
|
53
|
+
if (this.snapshots.length === 0) return;
|
|
54
|
+
|
|
55
|
+
this.wasRunning = this.isRunning();
|
|
56
|
+
if (this.wasRunning) this.stop(false);
|
|
57
|
+
|
|
58
|
+
this.isOpen = true;
|
|
59
|
+
this.savedState = this.processor.snapshotState();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const thumbnails = renderThumbnails(
|
|
63
|
+
this.processor,
|
|
64
|
+
this.snapshots,
|
|
65
|
+
this.video,
|
|
66
|
+
this.captureInterval,
|
|
67
|
+
this.savedState,
|
|
68
|
+
);
|
|
69
|
+
this._populateFilmstrip(thumbnails);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
this.processor.restoreState(this.savedState);
|
|
72
|
+
this._closePanel();
|
|
73
|
+
if (this.wasRunning) this.go();
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.panel.hidden = false;
|
|
78
|
+
// Use capture phase so keys don't leak to the emulator's keyboard handler
|
|
79
|
+
document.addEventListener("keydown", this._onKeyDown, true);
|
|
80
|
+
|
|
81
|
+
// Select the newest snapshot ("now") and jump to it
|
|
82
|
+
this.selectedIndex = this.snapshots.length - 1;
|
|
83
|
+
this._restoreAndPaint(this.selectedIndex);
|
|
84
|
+
this._updateSelectionHighlight();
|
|
85
|
+
this.filmstrip.scrollLeft = this.filmstrip.scrollWidth;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Close the rewind panel, committing the selected snapshot.
|
|
90
|
+
* The emulator resumes from the exact snapshot state.
|
|
91
|
+
*/
|
|
92
|
+
commit() {
|
|
93
|
+
if (!this.isOpen) return;
|
|
94
|
+
// Re-restore the snapshot so the CPU resumes from the exact point,
|
|
95
|
+
// not from the state advanced by executeUntilFrame during preview.
|
|
96
|
+
if (this.selectedIndex >= 0 && this.selectedIndex < this.snapshots.length) {
|
|
97
|
+
this.processor.restoreState(this.snapshots[this.selectedIndex]);
|
|
98
|
+
}
|
|
99
|
+
this._closePanel();
|
|
100
|
+
if (this.wasRunning) this.go();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Close the rewind panel, restoring the state from before it was opened.
|
|
105
|
+
*/
|
|
106
|
+
cancel() {
|
|
107
|
+
if (!this.isOpen) return;
|
|
108
|
+
this._renderState(this.savedState);
|
|
109
|
+
this._closePanel();
|
|
110
|
+
if (this.wasRunning) this.go();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Alias for cancel — closing the panel without explicit commit cancels. */
|
|
114
|
+
close() {
|
|
115
|
+
this.cancel();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Restore the emulator to the snapshot at the given index and update
|
|
120
|
+
* both the main display and the filmstrip selection highlight.
|
|
121
|
+
* @param {number} index - index into the snapshots array
|
|
122
|
+
*/
|
|
123
|
+
selectSnapshot(index) {
|
|
124
|
+
if (index < 0 || index >= this.snapshots.length) return;
|
|
125
|
+
|
|
126
|
+
this.selectedIndex = index;
|
|
127
|
+
this._restoreAndPaint(index);
|
|
128
|
+
this._updateSelectionHighlight();
|
|
129
|
+
this._scrollToSelected();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Update the disabled state of the Rewind menu item. */
|
|
133
|
+
updateButtonState() {
|
|
134
|
+
if (this.openBtn) {
|
|
135
|
+
this.openBtn.classList.toggle("disabled", this.rewindBuffer.length === 0);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_closePanel() {
|
|
140
|
+
this.isOpen = false;
|
|
141
|
+
this.panel.hidden = true;
|
|
142
|
+
this.filmstrip.innerHTML = "";
|
|
143
|
+
this.snapshots = [];
|
|
144
|
+
this.savedState = null;
|
|
145
|
+
document.removeEventListener("keydown", this._onKeyDown, true);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render a snapshot's frame to the display without advancing CPU state.
|
|
150
|
+
* Restores the snapshot, runs to vsync for a complete frame, paints it,
|
|
151
|
+
* then re-restores the snapshot so the CPU stays at the exact point.
|
|
152
|
+
*/
|
|
153
|
+
_restoreAndPaint(index) {
|
|
154
|
+
this._renderState(this.snapshots[index]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Render a state to the display, restoring it afterwards. */
|
|
158
|
+
_renderState(state) {
|
|
159
|
+
this.processor.restoreState(state);
|
|
160
|
+
executeUntilFrame(this.processor, this.video);
|
|
161
|
+
this.video.paint();
|
|
162
|
+
this.processor.restoreState(state);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_updateSelectionHighlight() {
|
|
166
|
+
const thumbs = this.filmstrip.querySelectorAll(".rewind-thumb");
|
|
167
|
+
for (const thumb of thumbs) {
|
|
168
|
+
thumb.classList.toggle("selected", Number(thumb.dataset.index) === this.selectedIndex);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_scrollToSelected() {
|
|
173
|
+
const selected = this.filmstrip.querySelector(".rewind-thumb.selected");
|
|
174
|
+
if (selected) {
|
|
175
|
+
selected.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_populateFilmstrip(thumbnails) {
|
|
180
|
+
this.filmstrip.innerHTML = "";
|
|
181
|
+
for (const { canvas, index, ageSeconds } of thumbnails) {
|
|
182
|
+
const wrapper = document.createElement("div");
|
|
183
|
+
wrapper.className = "rewind-thumb";
|
|
184
|
+
wrapper.dataset.index = index;
|
|
185
|
+
wrapper.appendChild(canvas);
|
|
186
|
+
|
|
187
|
+
const label = document.createElement("span");
|
|
188
|
+
label.className = "rewind-thumb-label";
|
|
189
|
+
label.textContent = ageSeconds === 0 ? "now" : `-${ageSeconds}s`;
|
|
190
|
+
wrapper.appendChild(label);
|
|
191
|
+
|
|
192
|
+
wrapper.addEventListener("click", () => this.selectSnapshot(index));
|
|
193
|
+
this.filmstrip.appendChild(wrapper);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_onKeyDown(e) {
|
|
198
|
+
switch (e.key) {
|
|
199
|
+
case "Escape":
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
e.stopPropagation();
|
|
202
|
+
this.cancel();
|
|
203
|
+
break;
|
|
204
|
+
case "Enter":
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
e.stopPropagation();
|
|
207
|
+
this.commit();
|
|
208
|
+
break;
|
|
209
|
+
case "ArrowLeft":
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
e.stopPropagation();
|
|
212
|
+
if (this.selectedIndex > 0) {
|
|
213
|
+
this.selectSnapshot(this.selectedIndex - 1);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
case "ArrowRight":
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
e.stopPropagation();
|
|
219
|
+
if (this.selectedIndex < this.snapshots.length - 1) {
|
|
220
|
+
this.selectSnapshot(this.selectedIndex + 1);
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
default:
|
|
224
|
+
// Block keys from reaching the emulator but allow browser
|
|
225
|
+
// shortcuts (Tab, accessibility keys, etc.) to work normally
|
|
226
|
+
e.stopPropagation();
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|