jsbeeb 1.6.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 +35 -6
- package/src/6502.opcodes.js +21 -8
- package/src/app/app.js +5 -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/main.js +120 -31
- package/src/rewind-thumbnail.js +118 -0
- package/src/rewind-ui.js +230 -0
- package/src/rewind.js +13 -0
- package/src/snapshot.js +38 -5
- package/src/soundchip.js +1 -5
- package/src/wd-fdc.js +99 -2
- package/tests/test-machine.js +134 -146
package/src/main.js
CHANGED
|
@@ -33,6 +33,7 @@ import { getFilterForMode } from "./canvas.js";
|
|
|
33
33
|
import { createSnapshot, restoreSnapshot, snapshotToJSON, snapshotFromJSON, modelsCompatible } from "./snapshot.js";
|
|
34
34
|
import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
|
|
35
35
|
import { RewindBuffer } from "./rewind.js";
|
|
36
|
+
import { RewindUI } from "./rewind-ui.js";
|
|
36
37
|
import {
|
|
37
38
|
buildUrlFromParams,
|
|
38
39
|
guessModelFromHostname,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
|
|
46
47
|
let processor;
|
|
47
48
|
let video;
|
|
49
|
+
let rewindUI;
|
|
48
50
|
const dbgr = new Debugger();
|
|
49
51
|
let frames = 0;
|
|
50
52
|
let frameSkip = 0;
|
|
@@ -450,8 +452,11 @@ function downloadDriveData(data, name, extension) {
|
|
|
450
452
|
}
|
|
451
453
|
|
|
452
454
|
async function loadHTMLFile(file) {
|
|
453
|
-
const
|
|
454
|
-
|
|
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);
|
|
455
460
|
delete parsedQuery.disc;
|
|
456
461
|
delete parsedQuery.disc1;
|
|
457
462
|
updateUrl();
|
|
@@ -791,6 +796,17 @@ keyboard.registerKeyHandler(
|
|
|
791
796
|
{ alt: false, ctrl: true },
|
|
792
797
|
);
|
|
793
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
|
+
|
|
794
810
|
keyboard.registerKeyHandler(
|
|
795
811
|
utils.keyCodes.B,
|
|
796
812
|
(down) => {
|
|
@@ -841,6 +857,12 @@ function setDisc1Image(name) {
|
|
|
841
857
|
config.emit("media-changed", { disc1: name });
|
|
842
858
|
}
|
|
843
859
|
|
|
860
|
+
function setDisc2Image(name) {
|
|
861
|
+
parsedQuery.disc2 = name;
|
|
862
|
+
updateUrl();
|
|
863
|
+
config.emit("media-changed", { disc2: name });
|
|
864
|
+
}
|
|
865
|
+
|
|
844
866
|
function setTapeImage(name) {
|
|
845
867
|
parsedQuery.tape = name;
|
|
846
868
|
updateUrl();
|
|
@@ -1036,6 +1058,51 @@ function splitImage(image) {
|
|
|
1036
1058
|
return { image: image, schema: schema };
|
|
1037
1059
|
}
|
|
1038
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
|
+
|
|
1039
1106
|
async function loadDiscImage(discImage) {
|
|
1040
1107
|
if (!discImage) return null;
|
|
1041
1108
|
const split = splitImage(discImage);
|
|
@@ -1361,8 +1428,17 @@ $("#download-filestore-link").on("click", function () {
|
|
|
1361
1428
|
downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
|
|
1362
1429
|
});
|
|
1363
1430
|
|
|
1364
|
-
|
|
1431
|
+
function hardReset() {
|
|
1432
|
+
if (rewindUI) {
|
|
1433
|
+
rewindUI.close();
|
|
1434
|
+
rewindBuffer.clear();
|
|
1435
|
+
rewindUI.updateButtonState();
|
|
1436
|
+
}
|
|
1365
1437
|
processor.reset(true);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
$("#hard-reset").click(function (event) {
|
|
1441
|
+
hardReset();
|
|
1366
1442
|
event.preventDefault();
|
|
1367
1443
|
});
|
|
1368
1444
|
|
|
@@ -1371,37 +1447,31 @@ $("#soft-reset").click(function (event) {
|
|
|
1371
1447
|
event.preventDefault();
|
|
1372
1448
|
});
|
|
1373
1449
|
|
|
1374
|
-
// Expose rewind to the debugger/console for v1
|
|
1375
|
-
window.jsbeebRewind = {
|
|
1376
|
-
step: function () {
|
|
1377
|
-
const snapshot = rewindBuffer.pop();
|
|
1378
|
-
if (!snapshot) {
|
|
1379
|
-
console.log("Rewind buffer empty");
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
const wasRunning = running;
|
|
1383
|
-
if (wasRunning) stop(false);
|
|
1384
|
-
processor.restoreState(snapshot);
|
|
1385
|
-
// Force a repaint so the display updates even while paused
|
|
1386
|
-
video.paint();
|
|
1387
|
-
console.log(`Rewound 1 step (${rewindBuffer.length} remaining)`);
|
|
1388
|
-
// Don't auto-resume - stay paused so user can inspect state
|
|
1389
|
-
},
|
|
1390
|
-
get length() {
|
|
1391
|
-
return rewindBuffer.length;
|
|
1392
|
-
},
|
|
1393
|
-
clear: function () {
|
|
1394
|
-
rewindBuffer.clear();
|
|
1395
|
-
console.log("Rewind buffer cleared");
|
|
1396
|
-
},
|
|
1397
|
-
};
|
|
1398
|
-
|
|
1399
1450
|
$("#save-state").click(async function (event) {
|
|
1400
1451
|
event.preventDefault();
|
|
1401
1452
|
const wasRunning = running;
|
|
1402
1453
|
if (running) stop(false);
|
|
1403
1454
|
try {
|
|
1404
|
-
const
|
|
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);
|
|
1405
1475
|
const json = snapshotToJSON(snapshot);
|
|
1406
1476
|
const blob = await compressBlob(new Blob([json]));
|
|
1407
1477
|
const url = URL.createObjectURL(blob);
|
|
@@ -1445,6 +1515,9 @@ async function loadStateFromFile(file) {
|
|
|
1445
1515
|
window.location.href = buildUrlFromParams(baseUrl, newQuery, paramTypes);
|
|
1446
1516
|
return;
|
|
1447
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);
|
|
1448
1521
|
restoreSnapshot(processor, model, snapshot);
|
|
1449
1522
|
// Force a repaint so the display updates even while paused
|
|
1450
1523
|
video.paint();
|
|
@@ -1597,7 +1670,7 @@ const startPromise = (async () => {
|
|
|
1597
1670
|
})();
|
|
1598
1671
|
|
|
1599
1672
|
startPromise
|
|
1600
|
-
.then(() => {
|
|
1673
|
+
.then(async () => {
|
|
1601
1674
|
switch (needsAutoboot) {
|
|
1602
1675
|
case "boot":
|
|
1603
1676
|
$sthAutoboot.prop("checked", true);
|
|
@@ -1627,6 +1700,9 @@ startPromise
|
|
|
1627
1700
|
sessionStorage.removeItem("jsbeeb-pending-state");
|
|
1628
1701
|
try {
|
|
1629
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);
|
|
1630
1706
|
restoreSnapshot(processor, model, snapshot);
|
|
1631
1707
|
processor.execute(40000);
|
|
1632
1708
|
} catch (e) {
|
|
@@ -1732,6 +1808,17 @@ const rewindBuffer = new RewindBuffer(30);
|
|
|
1732
1808
|
let rewindFrameCounter = 0;
|
|
1733
1809
|
const RewindCaptureInterval = 50; // ~1 second at 50fps
|
|
1734
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
|
+
|
|
1735
1822
|
function draw(now) {
|
|
1736
1823
|
if (!running) {
|
|
1737
1824
|
last = 0;
|
|
@@ -1788,6 +1875,7 @@ function draw(now) {
|
|
|
1788
1875
|
if (++rewindFrameCounter >= RewindCaptureInterval) {
|
|
1789
1876
|
rewindFrameCounter = 0;
|
|
1790
1877
|
rewindBuffer.push(processor.snapshotState());
|
|
1878
|
+
rewindUI.updateButtonState();
|
|
1791
1879
|
}
|
|
1792
1880
|
} catch (e) {
|
|
1793
1881
|
running = false;
|
|
@@ -1973,8 +2061,9 @@ electron({
|
|
|
1973
2061
|
loadStateFile: loadStateFromFile,
|
|
1974
2062
|
actions: {
|
|
1975
2063
|
"soft-reset": () => processor.reset(false),
|
|
1976
|
-
"hard-reset":
|
|
2064
|
+
"hard-reset": hardReset,
|
|
1977
2065
|
"save-state": () => $("#save-state").trigger("click"),
|
|
2066
|
+
rewind: () => rewindUI.open(),
|
|
1978
2067
|
},
|
|
1979
2068
|
});
|
|
1980
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
|
+
}
|
package/src/rewind.js
CHANGED
|
@@ -61,6 +61,19 @@ export class RewindBuffer {
|
|
|
61
61
|
this.writeIndex = 0;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Return all snapshots in order from oldest to newest.
|
|
66
|
+
* @returns {object[]} array of snapshots
|
|
67
|
+
*/
|
|
68
|
+
getAll() {
|
|
69
|
+
const result = new Array(this.count);
|
|
70
|
+
const start = (this.writeIndex - this.count + this.maxSnapshots) % this.maxSnapshots;
|
|
71
|
+
for (let i = 0; i < this.count; i++) {
|
|
72
|
+
result[i] = this.snapshots[(start + i) % this.maxSnapshots];
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
/**
|
|
65
78
|
* Number of snapshots currently stored.
|
|
66
79
|
* @returns {number}
|
package/src/snapshot.js
CHANGED
|
@@ -4,7 +4,7 @@ import { typedArrayToBase64, base64ToTypedArray } from "./state-utils.js";
|
|
|
4
4
|
import { findModel } from "./models.js";
|
|
5
5
|
|
|
6
6
|
const SnapshotFormat = "jsbeeb-snapshot";
|
|
7
|
-
const SnapshotVersion =
|
|
7
|
+
const SnapshotVersion = 2;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Check if two model names are compatible for state restore.
|
|
@@ -38,19 +38,52 @@ const TypedArrayConstructors = {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Create a snapshot of the emulator state.
|
|
41
|
+
* Create a snapshot of the emulator state for save-to-file.
|
|
42
|
+
* Disc track pulse data is stripped — on restore, the discs are reloaded
|
|
43
|
+
* from the source references in the `media` field. (The in-memory rewind
|
|
44
|
+
* path uses cpu.snapshotState() directly, which retains full disc data.)
|
|
42
45
|
* @param {import('./6502.js').Cpu6502} cpu
|
|
43
46
|
* @param {object} model - the model definition object
|
|
47
|
+
* @param {object} [media] - optional media source references (disc1, disc2)
|
|
44
48
|
* @returns {object} snapshot object
|
|
45
49
|
*/
|
|
46
|
-
export function createSnapshot(cpu, model) {
|
|
47
|
-
|
|
50
|
+
export function createSnapshot(cpu, model, media) {
|
|
51
|
+
const state = cpu.snapshotState();
|
|
52
|
+
// Strip clean disc track data from the save-to-file snapshot.
|
|
53
|
+
// The FDC/drive mechanical state is kept; only clean tracks
|
|
54
|
+
// (which can be reloaded from the disc image) are removed.
|
|
55
|
+
// Dirty tracks (written since disc load) are kept as an overlay.
|
|
56
|
+
if (state.fdc && state.fdc.drives) {
|
|
57
|
+
for (const drive of state.fdc.drives) {
|
|
58
|
+
if (drive.disc) {
|
|
59
|
+
const dirtyTracks = {};
|
|
60
|
+
for (const key of Object.keys(drive.disc.tracks)) {
|
|
61
|
+
const [sideStr, trackNumStr] = key.split(":");
|
|
62
|
+
const isSideUpper = sideStr === "true";
|
|
63
|
+
const trackNum = parseInt(trackNumStr, 10);
|
|
64
|
+
const dirtyKey = trackNum | (isSideUpper ? 0x100 : 0);
|
|
65
|
+
if (drive.disc._everDirtyTracks && drive.disc._everDirtyTracks.has(dirtyKey)) {
|
|
66
|
+
dirtyTracks[key] = drive.disc.tracks[key];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
drive.disc.tracks = {};
|
|
70
|
+
drive.disc.dirtyTracks = dirtyTracks;
|
|
71
|
+
// Clean up internal-only fields not needed in serialized state
|
|
72
|
+
delete drive.disc._everDirtyTracks;
|
|
73
|
+
delete drive.disc._originalImageData;
|
|
74
|
+
delete drive.disc._originalImageCrc32;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const snapshot = {
|
|
48
79
|
format: SnapshotFormat,
|
|
49
80
|
version: SnapshotVersion,
|
|
50
81
|
model: model.name,
|
|
51
82
|
timestamp: new Date().toISOString(),
|
|
52
|
-
state
|
|
83
|
+
state,
|
|
53
84
|
};
|
|
85
|
+
if (media) snapshot.media = media;
|
|
86
|
+
return snapshot;
|
|
54
87
|
}
|
|
55
88
|
|
|
56
89
|
/**
|
package/src/soundchip.js
CHANGED
|
@@ -377,11 +377,7 @@ export class InstrumentedSoundChip extends SoundChip {
|
|
|
377
377
|
/** Read current SN76489 register state in a friendly format. */
|
|
378
378
|
getState() {
|
|
379
379
|
return {
|
|
380
|
-
tone: [
|
|
381
|
-
this.registers[0],
|
|
382
|
-
this.registers[1],
|
|
383
|
-
this.registers[2],
|
|
384
|
-
],
|
|
380
|
+
tone: [this.registers[0], this.registers[1], this.registers[2]],
|
|
385
381
|
noise: this.registers[3],
|
|
386
382
|
volume: [
|
|
387
383
|
this._attenuationFromVolume(0),
|