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.
- package/package.json +1 -1
- package/src/6502.js +84 -0
- package/src/acia.js +42 -0
- package/src/adc.js +22 -0
- package/src/app/app.js +24 -0
- package/src/app/electron.js +10 -1
- package/src/app/preload.js +1 -0
- package/src/bem-snapshot.js +681 -0
- package/src/machine-session.js +91 -0
- package/src/main.js +137 -1
- package/src/rewind.js +71 -0
- package/src/scheduler.js +28 -0
- package/src/snapshot.js +110 -0
- package/src/soundchip.js +39 -0
- package/src/state-utils.js +66 -0
- package/src/teletext.js +69 -0
- package/src/via.js +91 -0
- package/src/video.js +143 -0
package/src/machine-session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/snapshot.js
ADDED
|
@@ -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
|
+
}
|