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/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 binaryData = await readFileAsBinaryString(file);
441
- processor.fdc.loadDisc(0, disc.discFor(processor.fdc, file.name, binaryData));
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
- await loadHTMLFile(file);
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
- $("#hard-reset").click(function (event) {
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": () => processor.reset(true),
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 };
@@ -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
+ }