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/rewind.js ADDED
@@ -0,0 +1,84 @@
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
+ * 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
+
77
+ /**
78
+ * Number of snapshots currently stored.
79
+ * @returns {number}
80
+ */
81
+ get length() {
82
+ return this.count;
83
+ }
84
+ }
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,143 @@
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 = 2;
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 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.)
45
+ * @param {import('./6502.js').Cpu6502} cpu
46
+ * @param {object} model - the model definition object
47
+ * @param {object} [media] - optional media source references (disc1, disc2)
48
+ * @returns {object} snapshot object
49
+ */
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 = {
79
+ format: SnapshotFormat,
80
+ version: SnapshotVersion,
81
+ model: model.name,
82
+ timestamp: new Date().toISOString(),
83
+ state,
84
+ };
85
+ if (media) snapshot.media = media;
86
+ return snapshot;
87
+ }
88
+
89
+ /**
90
+ * Restore emulator state from a snapshot.
91
+ * @param {import('./6502.js').Cpu6502} cpu
92
+ * @param {object} model - the current model definition
93
+ * @param {object} snapshot
94
+ * @throws {Error} if the model doesn't match
95
+ */
96
+ export function restoreSnapshot(cpu, model, snapshot) {
97
+ if (snapshot.format !== SnapshotFormat) {
98
+ throw new Error(`Unknown snapshot format: ${snapshot.format}`);
99
+ }
100
+ if (snapshot.version > SnapshotVersion) {
101
+ throw new Error(`Snapshot version ${snapshot.version} is newer than supported version ${SnapshotVersion}`);
102
+ }
103
+ if (!modelsCompatible(snapshot.model, model.name)) {
104
+ throw new Error(`Model mismatch: snapshot is for "${snapshot.model}" but current model is "${model.name}"`);
105
+ }
106
+ cpu.restoreState(snapshot.state);
107
+ }
108
+
109
+ /**
110
+ * Serialize a snapshot to a JSON string, converting TypedArrays to base64.
111
+ * @param {object} snapshot
112
+ * @returns {string} JSON string
113
+ */
114
+ export function snapshotToJSON(snapshot) {
115
+ return JSON.stringify(snapshot, (key, value) => {
116
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
117
+ return {
118
+ __typedArray: true,
119
+ type: value.constructor.name,
120
+ data: typedArrayToBase64(value),
121
+ };
122
+ }
123
+ return value;
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Deserialize a snapshot from a JSON string, converting base64 back to TypedArrays.
129
+ * @param {string} json
130
+ * @returns {object} snapshot object
131
+ */
132
+ export function snapshotFromJSON(json) {
133
+ return JSON.parse(json, (key, value) => {
134
+ if (value && value.__typedArray) {
135
+ const Constructor = TypedArrayConstructors[value.type];
136
+ if (!Constructor) {
137
+ throw new Error(`Unknown TypedArray type: ${value.type}`);
138
+ }
139
+ return base64ToTypedArray(value.data, Constructor);
140
+ }
141
+ return value;
142
+ });
143
+ }
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) {
@@ -338,11 +377,7 @@ export class InstrumentedSoundChip extends SoundChip {
338
377
  /** Read current SN76489 register state in a friendly format. */
339
378
  getState() {
340
379
  return {
341
- tone: [
342
- this.registers[0],
343
- this.registers[1],
344
- this.registers[2],
345
- ],
380
+ tone: [this.registers[0], this.registers[1], this.registers[2]],
346
381
  noise: this.registers[3],
347
382
  volume: [
348
383
  this._attenuationFromVolume(0),
@@ -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
+ }
package/src/teletext.js CHANGED
@@ -150,6 +150,75 @@ export class Teletext {
150
150
  }
151
151
  }
152
152
 
153
+ snapshotState() {
154
+ // Encode glyph table references as strings for serialization
155
+ let nextGlyphsName, curGlyphsName, heldGlyphsName;
156
+ for (const [name, table] of [
157
+ ["normal", this.normalGlyphs],
158
+ ["graphics", this.graphicsGlyphs],
159
+ ["separated", this.separatedGlyphs],
160
+ ]) {
161
+ if (this.nextGlyphs === table) nextGlyphsName = name;
162
+ if (this.curGlyphs === table) curGlyphsName = name;
163
+ if (this.heldGlyphs === table) heldGlyphsName = name;
164
+ }
165
+ return {
166
+ prevCol: this.prevCol,
167
+ col: this.col,
168
+ bg: this.bg,
169
+ sep: this.sep,
170
+ dbl: this.dbl,
171
+ oldDbl: this.oldDbl,
172
+ secondHalfOfDouble: this.secondHalfOfDouble,
173
+ wasDbl: this.wasDbl,
174
+ gfx: this.gfx,
175
+ flash: this.flash,
176
+ flashOn: this.flashOn,
177
+ flashTime: this.flashTime,
178
+ heldChar: this.heldChar,
179
+ holdChar: this.holdChar,
180
+ dataQueue: [...this.dataQueue],
181
+ scanlineCounter: this.scanlineCounter,
182
+ levelDEW: this.levelDEW,
183
+ levelDISPTMG: this.levelDISPTMG,
184
+ levelRA0: this.levelRA0,
185
+ nextGlyphs: nextGlyphsName,
186
+ curGlyphs: curGlyphsName,
187
+ heldGlyphs: heldGlyphsName,
188
+ };
189
+ }
190
+
191
+ restoreState(state) {
192
+ this.prevCol = state.prevCol;
193
+ this.col = state.col;
194
+ this.bg = state.bg;
195
+ this.sep = state.sep;
196
+ this.dbl = state.dbl;
197
+ this.oldDbl = state.oldDbl;
198
+ this.secondHalfOfDouble = state.secondHalfOfDouble;
199
+ this.wasDbl = state.wasDbl;
200
+ this.gfx = state.gfx;
201
+ this.flash = state.flash;
202
+ this.flashOn = state.flashOn;
203
+ this.flashTime = state.flashTime;
204
+ this.heldChar = state.heldChar;
205
+ this.holdChar = state.holdChar;
206
+ this.dataQueue = [...state.dataQueue];
207
+ this.scanlineCounter = state.scanlineCounter;
208
+ this.levelDEW = state.levelDEW;
209
+ this.levelDISPTMG = state.levelDISPTMG;
210
+ this.levelRA0 = state.levelRA0;
211
+
212
+ const glyphMap = {
213
+ normal: this.normalGlyphs,
214
+ graphics: this.graphicsGlyphs,
215
+ separated: this.separatedGlyphs,
216
+ };
217
+ this.nextGlyphs = glyphMap[state.nextGlyphs] || this.normalGlyphs;
218
+ this.curGlyphs = glyphMap[state.curGlyphs] || this.normalGlyphs;
219
+ this.heldGlyphs = glyphMap[state.heldGlyphs] || this.normalGlyphs;
220
+ }
221
+
153
222
  setNextChars() {
154
223
  if (this.gfx) {
155
224
  if (this.sep) {
package/src/via.js CHANGED
@@ -402,6 +402,73 @@ class Via {
402
402
  this.portBUpdated();
403
403
  }
404
404
 
405
+ snapshotState() {
406
+ return {
407
+ ora: this.ora,
408
+ orb: this.orb,
409
+ ira: this.ira,
410
+ irb: this.irb,
411
+ ddra: this.ddra,
412
+ ddrb: this.ddrb,
413
+ sr: this.sr,
414
+ t1l: this.t1l,
415
+ t2l: this.t2l,
416
+ t1c: this.t1c,
417
+ t2c: this.t2c,
418
+ acr: this.acr,
419
+ pcr: this.pcr,
420
+ ifr: this.ifr,
421
+ ier: this.ier,
422
+ t1hit: this.t1hit,
423
+ t2hit: this.t2hit,
424
+ portapins: this.portapins,
425
+ portbpins: this.portbpins,
426
+ ca1: this.ca1,
427
+ ca2: this.ca2,
428
+ cb1: this.cb1,
429
+ cb2: this.cb2,
430
+ justhit: this.justhit,
431
+ t1_pb7: this.t1_pb7,
432
+ lastPolltime: this.lastPolltime,
433
+ taskOffset: this.task.scheduled() ? this.task.expireEpoch - this.scheduler.epoch : null,
434
+ };
435
+ }
436
+
437
+ restoreState(state) {
438
+ this.ora = state.ora;
439
+ this.orb = state.orb;
440
+ this.ira = state.ira;
441
+ this.irb = state.irb;
442
+ this.ddra = state.ddra;
443
+ this.ddrb = state.ddrb;
444
+ this.sr = state.sr;
445
+ this.t1l = state.t1l;
446
+ this.t2l = state.t2l;
447
+ this.t1c = state.t1c;
448
+ this.t2c = state.t2c;
449
+ this.acr = state.acr;
450
+ this.pcr = state.pcr;
451
+ this.ifr = state.ifr;
452
+ this.ier = state.ier;
453
+ this.t1hit = state.t1hit;
454
+ this.t2hit = state.t2hit;
455
+ this.portapins = state.portapins;
456
+ this.portbpins = state.portbpins;
457
+ this.ca1 = state.ca1;
458
+ this.ca2 = state.ca2;
459
+ this.cb1 = state.cb1;
460
+ this.cb2 = state.cb2;
461
+ this.justhit = state.justhit;
462
+ this.t1_pb7 = state.t1_pb7;
463
+ this.lastPolltime = state.lastPolltime;
464
+ this.updateIFR();
465
+ if (state.taskOffset !== null) {
466
+ this.task.reschedule(state.taskOffset);
467
+ } else {
468
+ this.task.cancel();
469
+ }
470
+ }
471
+
405
472
  setca1(level) {
406
473
  if (level === this.ca1) return;
407
474
  const pcrSet = !!(this.pcr & 1);
@@ -484,6 +551,30 @@ export class SysVia extends Via {
484
551
  this.reset();
485
552
  }
486
553
 
554
+ snapshotState() {
555
+ return {
556
+ ...super.snapshotState(),
557
+ IC32: this.IC32,
558
+ capsLockLight: this.capsLockLight,
559
+ shiftLockLight: this.shiftLockLight,
560
+ };
561
+ }
562
+
563
+ restoreState(state) {
564
+ super.restoreState(state);
565
+ this.IC32 = state.IC32;
566
+ this.capsLockLight = state.capsLockLight;
567
+ this.shiftLockLight = state.shiftLockLight;
568
+ // Re-apply IC32 side effects (sound chip data bus, CMOS, port A)
569
+ // Must call portBUpdated directly (not recalculatePortBPins which
570
+ // overwrites IC32 based on portbpins) and then re-set IC32.
571
+ this.portBUpdated();
572
+ this.IC32 = state.IC32;
573
+ this.capsLockLight = state.capsLockLight;
574
+ this.shiftLockLight = state.shiftLockLight;
575
+ this.recalculatePortAPins();
576
+ }
577
+
487
578
  setKeyLayout(map) {
488
579
  this.keycodeToRowCol = utils.getKeyMap(map);
489
580
  }