jsbeeb 1.6.0 → 1.8.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
@@ -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 binaryData = await readFileAsBinaryString(file);
454
- 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);
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
- $("#hard-reset").click(function (event) {
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 snapshot = createSnapshot(processor, model);
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": () => processor.reset(true),
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 };
@@ -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 = 1;
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
- return {
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: cpu.snapshotState(),
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),