jsbeeb 1.5.0 → 1.6.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.
@@ -62,6 +62,10 @@ export class MachineSession {
62
62
 
63
63
  // Accumulated VDU text output — drained by callers
64
64
  this._pendingOutput = [];
65
+
66
+ // Breakpoint management — persistent hooks that survive across run calls
67
+ this._breakpoints = new Map(); // id → { hook, type, address, hit }
68
+ this._nextBreakpointId = 1;
65
69
  }
66
70
 
67
71
  /** Load ROMs and hardware — call once before anything else */
@@ -277,6 +281,93 @@ export class MachineSession {
277
281
  await this._machine.runUntilAddress(addr, timeoutSecs);
278
282
  }
279
283
 
284
+ /**
285
+ * Add a persistent breakpoint. Returns the breakpoint id.
286
+ * The hook stays active across run calls until removed.
287
+ * When the hook fires, cpu.stop() halts the current runFor.
288
+ * @param {"execute"|"read"|"write"} type
289
+ * @param {number} address
290
+ * @returns {number} breakpoint id
291
+ */
292
+ addBreakpoint(type, address) {
293
+ const id = this._nextBreakpointId++;
294
+ const cpu = this._machine.processor;
295
+ const bp = { type, address, hit: false, id, value: undefined };
296
+
297
+ if (type === "execute") {
298
+ bp.hook = cpu.debugInstruction.add((pc) => {
299
+ if (pc === address) {
300
+ bp.hit = true;
301
+ return true;
302
+ }
303
+ });
304
+ } else if (type === "read") {
305
+ bp.hook = cpu.debugRead.add((addr, val) => {
306
+ if (addr === address) {
307
+ bp.hit = true;
308
+ bp.value = val;
309
+ return true;
310
+ }
311
+ });
312
+ } else if (type === "write") {
313
+ bp.hook = cpu.debugWrite.add((addr, val) => {
314
+ if (addr === address) {
315
+ bp.hit = true;
316
+ bp.value = val;
317
+ return true;
318
+ }
319
+ });
320
+ } else {
321
+ throw new Error(`Unknown breakpoint type: ${type}`);
322
+ }
323
+
324
+ this._breakpoints.set(id, bp);
325
+ return id;
326
+ }
327
+
328
+ /**
329
+ * Remove a breakpoint by id.
330
+ */
331
+ removeBreakpoint(id) {
332
+ const bp = this._breakpoints.get(id);
333
+ if (!bp) throw new Error(`No breakpoint with id ${id}`);
334
+ bp.hook.remove();
335
+ this._breakpoints.delete(id);
336
+ }
337
+
338
+ /**
339
+ * Remove all breakpoints.
340
+ */
341
+ clearBreakpoints() {
342
+ for (const bp of this._breakpoints.values()) {
343
+ bp.hook.remove();
344
+ }
345
+ this._breakpoints.clear();
346
+ }
347
+
348
+ /**
349
+ * Return the first breakpoint that was hit since the last reset, or null.
350
+ */
351
+ hitBreakpoint() {
352
+ for (const bp of this._breakpoints.values()) {
353
+ if (bp.hit) {
354
+ const result = { id: bp.id, type: bp.type, address: bp.address };
355
+ if (bp.value !== undefined) result.value = bp.value;
356
+ return result;
357
+ }
358
+ }
359
+ return null;
360
+ }
361
+
362
+ /**
363
+ * Reset all hit flags (call before starting a new run).
364
+ */
365
+ resetBreakpointHits() {
366
+ for (const bp of this._breakpoints.values()) {
367
+ bp.hit = false;
368
+ }
369
+ }
370
+
280
371
  /**
281
372
  * Load a disc image (absolute or relative path to an .ssd or .dsd file).
282
373
  *
package/src/main.js CHANGED
@@ -30,6 +30,9 @@ 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";
33
36
  import {
34
37
  buildUrlFromParams,
35
38
  guessModelFromHostname,
@@ -327,6 +330,16 @@ const $screen = $("#screen");
327
330
  const $errorDialog = $("#error-dialog");
328
331
  const $errorDialogModal = new bootstrap.Modal($errorDialog[0]);
329
332
 
333
+ async function compressBlob(blob) {
334
+ const stream = blob.stream().pipeThrough(new CompressionStream("gzip"));
335
+ return new Response(stream).blob();
336
+ }
337
+
338
+ async function decompressBlob(blob) {
339
+ const stream = blob.stream().pipeThrough(new DecompressionStream("gzip"));
340
+ return new Response(stream).blob();
341
+ }
342
+
330
343
  function showError(context, error) {
331
344
  $errorDialog.find(".context").text(context);
332
345
  $errorDialog.find(".error").text(error);
@@ -474,7 +487,11 @@ $pastetext.on("dragover", function (event) {
474
487
  $pastetext.on("drop", async function (event) {
475
488
  utils.noteEvent("local", "drop");
476
489
  const file = event.originalEvent.dataTransfer.files[0];
477
- await loadHTMLFile(file);
490
+ if (isSnapshotFile(file.name)) {
491
+ await loadStateFromFile(file);
492
+ } else {
493
+ await loadHTMLFile(file);
494
+ }
478
495
  });
479
496
 
480
497
  const $cub = $("#cub-monitor");
@@ -1354,6 +1371,101 @@ $("#soft-reset").click(function (event) {
1354
1371
  event.preventDefault();
1355
1372
  });
1356
1373
 
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
+ $("#save-state").click(async function (event) {
1400
+ event.preventDefault();
1401
+ const wasRunning = running;
1402
+ if (running) stop(false);
1403
+ try {
1404
+ const snapshot = createSnapshot(processor, model);
1405
+ const json = snapshotToJSON(snapshot);
1406
+ const blob = await compressBlob(new Blob([json]));
1407
+ const url = URL.createObjectURL(blob);
1408
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1409
+ const a = document.createElement("a");
1410
+ a.href = url;
1411
+ a.download = `jsbeeb-${model.name}-${timestamp}.json.gz`;
1412
+ a.click();
1413
+ URL.revokeObjectURL(url);
1414
+ } catch (e) {
1415
+ showError("saving state", e);
1416
+ }
1417
+ if (wasRunning) go();
1418
+ });
1419
+
1420
+ async function loadStateFromFile(file) {
1421
+ const wasRunning = running;
1422
+ if (running) stop(false);
1423
+ try {
1424
+ const arrayBuffer = await file.arrayBuffer();
1425
+ let snapshot;
1426
+ if (isBemSnapshot(arrayBuffer)) {
1427
+ snapshot = await parseBemSnapshot(arrayBuffer);
1428
+ } else {
1429
+ // Detect gzip (magic bytes 0x1f 0x8b) or plain JSON
1430
+ const bytes = new Uint8Array(arrayBuffer);
1431
+ let text;
1432
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
1433
+ const decompressed = await decompressBlob(new Blob([arrayBuffer]));
1434
+ text = await decompressed.text();
1435
+ } else {
1436
+ text = new TextDecoder().decode(arrayBuffer);
1437
+ }
1438
+ snapshot = snapshotFromJSON(text);
1439
+ }
1440
+ if (!modelsCompatible(snapshot.model, model.name)) {
1441
+ // Model mismatch: stash state and reload with correct model
1442
+ sessionStorage.setItem("jsbeeb-pending-state", snapshotToJSON(snapshot));
1443
+ const newQuery = { ...parsedQuery, model: snapshot.model };
1444
+ const baseUrl = window.location.origin + window.location.pathname;
1445
+ window.location.href = buildUrlFromParams(baseUrl, newQuery, paramTypes);
1446
+ return;
1447
+ }
1448
+ restoreSnapshot(processor, model, snapshot);
1449
+ // Force a repaint so the display updates even while paused
1450
+ video.paint();
1451
+ } catch (e) {
1452
+ showError("loading state", e);
1453
+ }
1454
+ if (wasRunning) go();
1455
+ }
1456
+
1457
+ function isSnapshotFile(filename) {
1458
+ const lower = filename.toLowerCase();
1459
+ return lower.endsWith(".snp") || lower.endsWith(".json") || lower.endsWith(".json.gz") || lower.endsWith(".gz");
1460
+ }
1461
+
1462
+ $("#load-state").on("change", async function (event) {
1463
+ const file = event.target.files[0];
1464
+ if (!file) return;
1465
+ event.target.value = "";
1466
+ await loadStateFromFile(file);
1467
+ });
1468
+
1357
1469
  $("#tape-menu a").on("click", function (e) {
1358
1470
  const type = $(e.target).attr("data-id");
1359
1471
  if (type === undefined) return;
@@ -1509,6 +1621,19 @@ startPromise
1509
1621
  dbgr.setPatch(parsedQuery.patch);
1510
1622
  }
1511
1623
 
1624
+ // Restore pending state from a cross-model load (sessionStorage)
1625
+ const pendingState = sessionStorage.getItem("jsbeeb-pending-state");
1626
+ if (pendingState) {
1627
+ sessionStorage.removeItem("jsbeeb-pending-state");
1628
+ try {
1629
+ const snapshot = snapshotFromJSON(pendingState);
1630
+ restoreSnapshot(processor, model, snapshot);
1631
+ processor.execute(40000);
1632
+ } catch (e) {
1633
+ showError("restoring saved state", e);
1634
+ }
1635
+ }
1636
+
1512
1637
  go();
1513
1638
  })
1514
1639
  .catch((error) => {
@@ -1603,6 +1728,10 @@ function VirtualSpeedUpdater() {
1603
1728
 
1604
1729
  const virtualSpeedUpdater = new VirtualSpeedUpdater();
1605
1730
 
1731
+ const rewindBuffer = new RewindBuffer(30);
1732
+ let rewindFrameCounter = 0;
1733
+ const RewindCaptureInterval = 50; // ~1 second at 50fps
1734
+
1606
1735
  function draw(now) {
1607
1736
  if (!running) {
1608
1737
  last = 0;
@@ -1655,6 +1784,11 @@ function draw(now) {
1655
1784
  }
1656
1785
  const end = performance.now();
1657
1786
  virtualSpeedUpdater.update(cycles, end - now, speedy);
1787
+ // Capture rewind snapshot periodically
1788
+ if (++rewindFrameCounter >= RewindCaptureInterval) {
1789
+ rewindFrameCounter = 0;
1790
+ rewindBuffer.push(processor.snapshotState());
1791
+ }
1658
1792
  } catch (e) {
1659
1793
  running = false;
1660
1794
  utils.noteEvent("exception", "thrown", e.stack);
@@ -1836,9 +1970,11 @@ electron({
1836
1970
  }
1837
1971
  },
1838
1972
  },
1973
+ loadStateFile: loadStateFromFile,
1839
1974
  actions: {
1840
1975
  "soft-reset": () => processor.reset(false),
1841
1976
  "hard-reset": () => processor.reset(true),
1977
+ "save-state": () => $("#save-state").trigger("click"),
1842
1978
  },
1843
1979
  });
1844
1980
 
package/src/rewind.js ADDED
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Circular buffer of emulator state snapshots for rewind functionality.
5
+ * Snapshots are stored directly without deep-copying, since
6
+ * snapshotState() already clones all TypedArrays via .slice().
7
+ */
8
+ export class RewindBuffer {
9
+ /**
10
+ * @param {number} maxSnapshots - maximum number of snapshots to retain
11
+ */
12
+ constructor(maxSnapshots = 30) {
13
+ this.maxSnapshots = maxSnapshots;
14
+ this.snapshots = new Array(maxSnapshots);
15
+ this.count = 0;
16
+ this.writeIndex = 0;
17
+ }
18
+
19
+ /**
20
+ * Push a snapshot into the buffer.
21
+ * The caller must ensure the snapshot's typed arrays are already
22
+ * independent copies (e.g. from snapshotState() which uses .slice()).
23
+ * Overwrites the oldest snapshot when full.
24
+ * @param {object} snapshot - emulator state snapshot (already cloned)
25
+ */
26
+ push(snapshot) {
27
+ this.snapshots[this.writeIndex] = snapshot;
28
+ this.writeIndex = (this.writeIndex + 1) % this.maxSnapshots;
29
+ if (this.count < this.maxSnapshots) this.count++;
30
+ }
31
+
32
+ /**
33
+ * Pop the most recent snapshot from the buffer.
34
+ * @returns {object|null} the most recent snapshot, or null if empty
35
+ */
36
+ pop() {
37
+ if (this.count === 0) return null;
38
+ this.writeIndex = (this.writeIndex - 1 + this.maxSnapshots) % this.maxSnapshots;
39
+ this.count--;
40
+ const snapshot = this.snapshots[this.writeIndex];
41
+ this.snapshots[this.writeIndex] = null;
42
+ return snapshot;
43
+ }
44
+
45
+ /**
46
+ * Peek at the most recent snapshot without removing it.
47
+ * @returns {object|null} the most recent snapshot, or null if empty
48
+ */
49
+ peek() {
50
+ if (this.count === 0) return null;
51
+ const index = (this.writeIndex - 1 + this.maxSnapshots) % this.maxSnapshots;
52
+ return this.snapshots[index];
53
+ }
54
+
55
+ /**
56
+ * Clear all snapshots from the buffer.
57
+ */
58
+ clear() {
59
+ this.snapshots.fill(null);
60
+ this.count = 0;
61
+ this.writeIndex = 0;
62
+ }
63
+
64
+ /**
65
+ * Number of snapshots currently stored.
66
+ * @returns {number}
67
+ */
68
+ get length() {
69
+ return this.count;
70
+ }
71
+ }
package/src/scheduler.js CHANGED
@@ -87,6 +87,34 @@ export class Scheduler {
87
87
  return this.scheduled.expireEpoch - this.epoch;
88
88
  }
89
89
 
90
+ /**
91
+ * Cancel all scheduled tasks.
92
+ * Used during state restore - components will re-register their own tasks.
93
+ */
94
+ cancelAll() {
95
+ while (this.scheduled) {
96
+ this.scheduled.cancel();
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Capture the scheduler's state for snapshotting.
102
+ * @returns {{epoch: number}}
103
+ */
104
+ snapshotState() {
105
+ return { epoch: this.epoch };
106
+ }
107
+
108
+ /**
109
+ * Restore the scheduler's state from a snapshot.
110
+ * Cancels all existing tasks - components must re-register theirs after this call.
111
+ * @param {{epoch: number}} state
112
+ */
113
+ restoreState(state) {
114
+ this.cancelAll();
115
+ this.epoch = state.epoch;
116
+ }
117
+
90
118
  /**
91
119
  * Create a new task.
92
120
  * @param {function(): void} onExpire function to call when the task expires
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ import { typedArrayToBase64, base64ToTypedArray } from "./state-utils.js";
4
+ import { findModel } from "./models.js";
5
+
6
+ const SnapshotFormat = "jsbeeb-snapshot";
7
+ const SnapshotVersion = 1;
8
+
9
+ /**
10
+ * Check if two model names are compatible for state restore.
11
+ * Resolves synonyms via findModel, then compares by stripping any
12
+ * trailing parenthesised suffix (e.g. treating "BBC Master 128 (DFS)"
13
+ * and "BBC Master 128 (ADFS)" as compatible). Does not treat
14
+ * differently-named models like "BBC B with DFS 0.9" vs "BBC B with DFS 1.2"
15
+ * as compatible — those are distinct models.
16
+ */
17
+ export function modelsCompatible(snapshotModel, currentModel) {
18
+ if (snapshotModel === currentModel) return true;
19
+ const resolvedSnapshot = findModel(snapshotModel);
20
+ const resolvedCurrent = findModel(currentModel);
21
+ if (resolvedSnapshot && resolvedCurrent) {
22
+ // Same model object, or same base machine (strip filesystem suffix)
23
+ if (resolvedSnapshot === resolvedCurrent) return true;
24
+ const base = (name) => name.replace(/\s*\(.*\)$/, "");
25
+ return base(resolvedSnapshot.name) === base(resolvedCurrent.name);
26
+ }
27
+ return false;
28
+ }
29
+
30
+ // Map of TypedArray constructor names for deserialization
31
+ const TypedArrayConstructors = {
32
+ Uint8Array,
33
+ Uint16Array,
34
+ Uint32Array,
35
+ Int32Array,
36
+ Float32Array,
37
+ Float64Array,
38
+ };
39
+
40
+ /**
41
+ * Create a snapshot of the emulator state.
42
+ * @param {import('./6502.js').Cpu6502} cpu
43
+ * @param {object} model - the model definition object
44
+ * @returns {object} snapshot object
45
+ */
46
+ export function createSnapshot(cpu, model) {
47
+ return {
48
+ format: SnapshotFormat,
49
+ version: SnapshotVersion,
50
+ model: model.name,
51
+ timestamp: new Date().toISOString(),
52
+ state: cpu.snapshotState(),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Restore emulator state from a snapshot.
58
+ * @param {import('./6502.js').Cpu6502} cpu
59
+ * @param {object} model - the current model definition
60
+ * @param {object} snapshot
61
+ * @throws {Error} if the model doesn't match
62
+ */
63
+ export function restoreSnapshot(cpu, model, snapshot) {
64
+ if (snapshot.format !== SnapshotFormat) {
65
+ throw new Error(`Unknown snapshot format: ${snapshot.format}`);
66
+ }
67
+ if (snapshot.version > SnapshotVersion) {
68
+ throw new Error(`Snapshot version ${snapshot.version} is newer than supported version ${SnapshotVersion}`);
69
+ }
70
+ if (!modelsCompatible(snapshot.model, model.name)) {
71
+ throw new Error(`Model mismatch: snapshot is for "${snapshot.model}" but current model is "${model.name}"`);
72
+ }
73
+ cpu.restoreState(snapshot.state);
74
+ }
75
+
76
+ /**
77
+ * Serialize a snapshot to a JSON string, converting TypedArrays to base64.
78
+ * @param {object} snapshot
79
+ * @returns {string} JSON string
80
+ */
81
+ export function snapshotToJSON(snapshot) {
82
+ return JSON.stringify(snapshot, (key, value) => {
83
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
84
+ return {
85
+ __typedArray: true,
86
+ type: value.constructor.name,
87
+ data: typedArrayToBase64(value),
88
+ };
89
+ }
90
+ return value;
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Deserialize a snapshot from a JSON string, converting base64 back to TypedArrays.
96
+ * @param {string} json
97
+ * @returns {object} snapshot object
98
+ */
99
+ export function snapshotFromJSON(json) {
100
+ return JSON.parse(json, (key, value) => {
101
+ if (value && value.__typedArray) {
102
+ const Constructor = TypedArrayConstructors[value.type];
103
+ if (!Constructor) {
104
+ throw new Error(`Unknown TypedArray type: ${value.type}`);
105
+ }
106
+ return base64ToTypedArray(value.data, Constructor);
107
+ }
108
+ return value;
109
+ });
110
+ }
package/src/soundchip.js CHANGED
@@ -263,6 +263,45 @@ export class SoundChip {
263
263
  }
264
264
  }
265
265
 
266
+ snapshotState() {
267
+ return {
268
+ registers: this.registers.slice(),
269
+ counter: this.counter.slice(),
270
+ outputBit: [...this.outputBit],
271
+ volume: this.volume.slice(),
272
+ lfsr: this.lfsr,
273
+ latchedRegister: this.latchedRegister,
274
+ residual: this.residual,
275
+ sineOn: this.sineOn,
276
+ sineStep: this.sineStep,
277
+ sineTime: this.sineTime,
278
+ };
279
+ }
280
+
281
+ restoreState(state) {
282
+ this.registers.set(state.registers);
283
+ this.counter.set(state.counter);
284
+ this.outputBit[0] = state.outputBit[0];
285
+ this.outputBit[1] = state.outputBit[1];
286
+ this.outputBit[2] = state.outputBit[2];
287
+ this.outputBit[3] = state.outputBit[3];
288
+ this.volume.set(state.volume);
289
+ this.lfsr = state.lfsr;
290
+ this.latchedRegister = state.latchedRegister;
291
+ // Sync to current scheduler epoch to avoid a catch-up burst
292
+ this.lastRunEpoch = this.scheduler.epoch;
293
+ this.residual = state.residual;
294
+ this.sineOn = state.sineOn;
295
+ this.sineStep = state.sineStep;
296
+ this.sineTime = state.sineTime;
297
+ // Rebind the LFSR function based on noise register
298
+ this.shiftLfsr =
299
+ this.registers[3] & 4 ? this.shiftLfsrWhiteNoise.bind(this) : this.shiftLfsrPeriodicNoise.bind(this);
300
+ // Reset output buffer
301
+ this.position = 0;
302
+ this.buffer.fill(0);
303
+ }
304
+
266
305
  reset(hard) {
267
306
  if (!hard) return;
268
307
  for (let i = 0; i < 4; ++i) {
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Convert a TypedArray to a base64 string for JSON serialization.
5
+ * @param {ArrayBufferView} typedArray
6
+ * @returns {string} base64-encoded string
7
+ */
8
+ export function typedArrayToBase64(typedArray) {
9
+ const bytes = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
10
+ // Build binary string in chunks to avoid excessive string concatenation
11
+ const chunkSize = 8192;
12
+ const parts = [];
13
+ for (let i = 0; i < bytes.length; i += chunkSize) {
14
+ const end = Math.min(i + chunkSize, bytes.length);
15
+ parts.push(String.fromCharCode(...bytes.subarray(i, end)));
16
+ }
17
+ return btoa(parts.join(""));
18
+ }
19
+
20
+ /**
21
+ * Convert a base64 string back to a TypedArray.
22
+ * @param {string} base64 base64-encoded string
23
+ * @param {function} TypedArrayConstructor constructor for the desired type (e.g., Uint8Array)
24
+ * @returns {ArrayBufferView} the decoded typed array
25
+ */
26
+ export function base64ToTypedArray(base64, TypedArrayConstructor) {
27
+ const binary = atob(base64);
28
+ const bytes = new Uint8Array(binary.length);
29
+ for (let i = 0; i < binary.length; i++) {
30
+ bytes[i] = binary.charCodeAt(i);
31
+ }
32
+ // Create a properly aligned typed array from the raw bytes
33
+ const elementSize = TypedArrayConstructor.BYTES_PER_ELEMENT;
34
+ const length = bytes.length / elementSize;
35
+ if (!Number.isInteger(length)) {
36
+ throw new Error(
37
+ `Base64 data length (${bytes.length} bytes) is not a multiple of ${TypedArrayConstructor.name} element size (${elementSize})`,
38
+ );
39
+ }
40
+ const result = new TypedArrayConstructor(length);
41
+ new Uint8Array(result.buffer).set(bytes);
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Deep copy a snapshot object, cloning any TypedArrays found within.
47
+ * This ensures rewind buffer snapshots are fully isolated from live state.
48
+ * @param {object} obj snapshot object to copy
49
+ * @returns {object} a deep copy with all TypedArrays cloned
50
+ */
51
+ export function deepCopySnapshot(obj) {
52
+ if (obj === null || typeof obj !== "object") {
53
+ return obj;
54
+ }
55
+ if (ArrayBuffer.isView(obj)) {
56
+ return obj.slice();
57
+ }
58
+ if (Array.isArray(obj)) {
59
+ return obj.map((item) => deepCopySnapshot(item));
60
+ }
61
+ const copy = {};
62
+ for (const key of Object.keys(obj)) {
63
+ copy[key] = deepCopySnapshot(obj[key]);
64
+ }
65
+ return copy;
66
+ }