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/disc.js CHANGED
@@ -3,30 +3,23 @@
3
3
 
4
4
  import * as utils from "./utils.js";
5
5
 
6
- /*
7
- * TODO: use in fingerprinting
8
- class Crc32Builder {
9
- constructor() {
10
- this._crc = 0xffffffff;
11
- }
12
-
13
- add(data) {
14
- for (let i = 0; i < data.length; ++i) {
15
- const byte = data[i];
16
- this._crc ^= byte;
17
- for (let j = 0; j < 8; ++j) {
18
- const doEor = this._crc & 1;
19
- this._crc = this._crc >>> 1;
20
- if (doEor) this._crc ^= 0xedb88320;
21
- }
6
+ /**
7
+ * Compute CRC32 of a Uint8Array.
8
+ * @param {Uint8Array} data
9
+ * @returns {number} CRC32 as a signed 32-bit integer
10
+ */
11
+ export function crc32(data) {
12
+ let crc = 0xffffffff;
13
+ for (let i = 0; i < data.length; ++i) {
14
+ crc ^= data[i];
15
+ for (let j = 0; j < 8; ++j) {
16
+ const doEor = crc & 1;
17
+ crc = crc >>> 1;
18
+ if (doEor) crc ^= 0xedb88320;
22
19
  }
23
20
  }
24
-
25
- get crc() {
26
- return ~this._crc;
27
- }
21
+ return ~crc;
28
22
  }
29
- */
30
23
  class TrackBuilder {
31
24
  /**
32
25
  * @param {Track} track
@@ -747,6 +740,27 @@ export class Disc {
747
740
  this.writeTrackCallback = undefined;
748
741
  this.isWriteable = isWriteable;
749
742
 
743
+ // Track which tracks have been written since the last snapshot.
744
+ // Keys are numeric: (track | (isSideUpper ? 0x100 : 0)).
745
+ this._snapshotDirtyTracks = new Set();
746
+ // Cumulative set of all tracks ever written since disc load (never cleared by snapshots).
747
+ // Same key encoding as _snapshotDirtyTracks.
748
+ this._everDirtyTracks = new Set();
749
+ // Cache of the previous snapshot's track data for structural sharing.
750
+ // Keys are "side:trackNum" strings, values are {pulses2Us, length} objects.
751
+ this._lastTrackSnapshots = Object.create(null);
752
+
753
+ // Original disc image data, stored for embedding in save-to-file snapshots
754
+ // and CRC32 verification on restore. _originalImageData is only set for
755
+ // local-file discs (not URL-sourced ones). _originalImageCrc32 is the CRC
756
+ // of the image bytes at load time — it is not updated if an onChange handler
757
+ // mutates the backing store. This is fine because the CRC is compared against
758
+ // the same source that would be reloaded (the original image or URL), not the
759
+ // mutated copy. If a mutable source URL were added in future, CRC verification
760
+ // should be skipped or the CRC updated accordingly.
761
+ this._originalImageData = null;
762
+ this._originalImageCrc32 = null;
763
+
750
764
  this.initSurface(0);
751
765
  }
752
766
 
@@ -754,6 +768,39 @@ export class Disc {
754
768
  this.writeTrackCallback = callback;
755
769
  }
756
770
 
771
+ /**
772
+ * Record the CRC32 of the original disc image for verification on restore.
773
+ * Called by discFor() after loading. Does not retain the image bytes.
774
+ * @param {Uint8Array} data - the raw disc image bytes (used only to compute CRC32)
775
+ */
776
+ setOriginalImageCrc32(data) {
777
+ this._originalImageCrc32 = crc32(data);
778
+ }
779
+
780
+ /**
781
+ * Store the original disc image bytes for embedding in snapshots.
782
+ * Only call this for local-file discs — URL-sourced discs should use
783
+ * setOriginalImageCrc32() instead to avoid retaining the full image.
784
+ * Computes CRC32 only if not already set (e.g. by discFor).
785
+ * @param {Uint8Array} data - the raw disc image bytes
786
+ */
787
+ setOriginalImage(data) {
788
+ this._originalImageData = data;
789
+ if (this._originalImageCrc32 == null) {
790
+ this._originalImageCrc32 = crc32(data);
791
+ }
792
+ }
793
+
794
+ /** @returns {Uint8Array|null} the original disc image bytes, or null if not set */
795
+ get originalImageData() {
796
+ return this._originalImageData;
797
+ }
798
+
799
+ /** @returns {number|null} CRC32 of the original disc image, or null if not set */
800
+ get originalImageCrc32() {
801
+ return this._originalImageCrc32;
802
+ }
803
+
757
804
  get writeProtected() {
758
805
  return !this.isWriteable;
759
806
  }
@@ -797,7 +844,9 @@ export class Disc {
797
844
  writePulses(isSideUpper, track, position, pulses) {
798
845
  const trackObj = this.getTrack(isSideUpper, track);
799
846
  if (position >= trackObj.length)
800
- throw new Error(`Attempt to write off end of track ${position} > ${track.length}`);
847
+ throw new Error(
848
+ `Attempt to write off end of track ${track}: position ${position} >= length ${trackObj.length}`,
849
+ );
801
850
  if (this.isDirty) {
802
851
  if (isSideUpper !== this.dirtySide || track !== this.dirtyTrack)
803
852
  throw new Error("Switched dirty track or side");
@@ -805,6 +854,11 @@ export class Disc {
805
854
  this.isDirty = true;
806
855
  this.dirtySide = isSideUpper;
807
856
  this.dirtyTrack = track;
857
+ // Numeric key avoids string allocation on every pulse write.
858
+ // Upper bit encodes side, lower 8 bits encode track number.
859
+ const dirtyKey = track | (isSideUpper ? 0x100 : 0);
860
+ this._snapshotDirtyTracks.add(dirtyKey);
861
+ this._everDirtyTracks.add(dirtyKey);
808
862
  trackObj.pulses2Us[position] = pulses;
809
863
  // TODO a debug log flag for this
810
864
  // console.log(`wrote to ${track}:${position * 32}`);
@@ -827,6 +881,106 @@ export class Disc {
827
881
  this.setTrackUsed(dirtySide, dirtyTrack);
828
882
  }
829
883
 
884
+ /**
885
+ * Create a snapshot of all track data with structural sharing.
886
+ * Clean tracks reuse references from the previous snapshot;
887
+ * dirty tracks get fresh copies.
888
+ */
889
+ snapshotState() {
890
+ const numSides = this.isDoubleSided ? 2 : 1;
891
+ // Use a plain object for JSON serialization compatibility.
892
+ // Keys are "side:trackNum" strings.
893
+ const tracks = Object.create(null);
894
+ for (let side = 0; side < numSides; ++side) {
895
+ for (let trackNum = 0; trackNum < this.tracksUsed; ++trackNum) {
896
+ const key = `${side === 1}:${trackNum}`;
897
+ const dirtyKey = trackNum | (side === 1 ? 0x100 : 0);
898
+ const trackObj = this.getTrack(side === 1, trackNum);
899
+ if (this._snapshotDirtyTracks.has(dirtyKey) || !this._lastTrackSnapshots[key]) {
900
+ // Dirty or first snapshot: copy the track data
901
+ tracks[key] = {
902
+ pulses2Us: trackObj.pulses2Us.slice(),
903
+ length: trackObj.length,
904
+ };
905
+ } else {
906
+ // Clean: reuse the previous snapshot's reference
907
+ tracks[key] = this._lastTrackSnapshots[key];
908
+ }
909
+ }
910
+ }
911
+
912
+ this._snapshotDirtyTracks.clear();
913
+ this._lastTrackSnapshots = tracks;
914
+
915
+ return {
916
+ tracksUsed: this.tracksUsed,
917
+ isDoubleSided: this.isDoubleSided,
918
+ isWriteable: this.isWriteable,
919
+ name: this.name,
920
+ tracks,
921
+ // Expose for save-to-file snapshots to identify dirty tracks.
922
+ // This is a Set and won't survive JSON serialization — it's only
923
+ // used by createSnapshot() before the snapshot is serialized.
924
+ _everDirtyTracks: new Set(this._everDirtyTracks),
925
+ _originalImageData: this._originalImageData,
926
+ _originalImageCrc32: this._originalImageCrc32,
927
+ };
928
+ }
929
+
930
+ /**
931
+ * Restore disc track data from a snapshot.
932
+ * For save-to-file snapshots, `state.tracks` is empty and `state.dirtyTracks`
933
+ * contains only the tracks modified since disc load. The base disc data must
934
+ * already be loaded before calling this method.
935
+ */
936
+ restoreState(state) {
937
+ this.tracksUsed = state.tracksUsed;
938
+ this.isDoubleSided = state.isDoubleSided;
939
+ this.isWriteable = state.isWriteable;
940
+ this.name = state.name;
941
+
942
+ // Reset write-in-progress state
943
+ this.isDirty = false;
944
+ this.dirtySide = -1;
945
+ this.dirtyTrack = -1;
946
+ this._snapshotDirtyTracks.clear();
947
+ // Restore _everDirtyTracks from the snapshot if present (rewind path —
948
+ // the Set is carried in-memory but won't survive JSON serialization).
949
+ // For the save-to-file path, _everDirtyTracks is rebuilt from dirtyTracks below.
950
+ if (state._everDirtyTracks instanceof Set) {
951
+ this._everDirtyTracks = new Set(state._everDirtyTracks);
952
+ } else {
953
+ this._everDirtyTracks.clear();
954
+ }
955
+
956
+ // Restore full track data (rewind path — tracks contains all data)
957
+ this._lastTrackSnapshots = state.tracks;
958
+ for (const key of Object.keys(state.tracks)) {
959
+ const trackData = state.tracks[key];
960
+ const [sideStr, trackNumStr] = key.split(":");
961
+ const isSideUpper = sideStr === "true";
962
+ const trackNum = parseInt(trackNumStr, 10);
963
+ const trackObj = this.getTrack(isSideUpper, trackNum);
964
+ trackObj.pulses2Us.set(trackData.pulses2Us);
965
+ trackObj.length = trackData.length;
966
+ }
967
+
968
+ // Apply dirty track overlays (save-to-file path — base disc already loaded,
969
+ // overlay only the tracks that were written since disc load)
970
+ if (state.dirtyTracks) {
971
+ for (const key of Object.keys(state.dirtyTracks)) {
972
+ const trackData = state.dirtyTracks[key];
973
+ const [sideStr, trackNumStr] = key.split(":");
974
+ const isSideUpper = sideStr === "true";
975
+ const trackNum = parseInt(trackNumStr, 10);
976
+ const trackObj = this.getTrack(isSideUpper, trackNum);
977
+ trackObj.pulses2Us.set(trackData.pulses2Us);
978
+ trackObj.length = trackData.length;
979
+ this._everDirtyTracks.add(trackNum | (isSideUpper ? 0x100 : 0));
980
+ }
981
+ }
982
+ }
983
+
830
984
  logSummary() {
831
985
  const maxTrack = this.tracksUsed;
832
986
  const numSides = this.isDoubleSided ? 2 : 1;
package/src/fdc.js CHANGED
@@ -212,7 +212,9 @@ export function guessDiscTypeFromName(name) {
212
212
  */
213
213
  export function discFor(fdc, name, stringData, onChange) {
214
214
  const data = typeof stringData !== "string" ? stringData : utils.stringToUint8Array(stringData);
215
- return guessDiscTypeFromName(name).loader(new Disc(true, new DiscConfig(), name), data, onChange);
215
+ const disc = guessDiscTypeFromName(name).loader(new Disc(true, new DiscConfig(), name), data, onChange);
216
+ disc.setOriginalImageCrc32(data instanceof Uint8Array ? data : new Uint8Array(data));
217
+ return disc;
216
218
  }
217
219
 
218
220
  export function localDisc(fdc, name) {
package/src/intel-fdc.js CHANGED
@@ -1670,6 +1670,71 @@ export class IntelFdc {
1670
1670
  this._doWriteRun(callContext, 0xff);
1671
1671
  }
1672
1672
 
1673
+ snapshotState() {
1674
+ const scheduler = this._timerTask.scheduler;
1675
+ return {
1676
+ regs: this._regs.slice(),
1677
+ status: this._status,
1678
+ isResultReady: this._isResultReady,
1679
+ mmioData: this._mmioData,
1680
+ mmioClocks: this._mmioClocks,
1681
+ driveOut: this._driveOut,
1682
+ shiftRegister: this._shiftRegister,
1683
+ numShifts: this._numShifts,
1684
+ state: this._state,
1685
+ stateCount: this._stateCount,
1686
+ stateIsIndexPulse: this._stateIsIndexPulse,
1687
+ crc: this._crc,
1688
+ onDiscCrc: this._onDiscCrc,
1689
+ paramCallback: this._paramCallback,
1690
+ indexPulseCallback: this._indexPulseCallback,
1691
+ timerState: this._timerState,
1692
+ callContext: this._callContext,
1693
+ didSeekStep: this._didSeekStep,
1694
+ timerTaskOffset: this._timerTask.scheduled() ? this._timerTask.expireEpoch - scheduler.epoch : null,
1695
+ drives: this._drives.map((d) => d.snapshotState()),
1696
+ };
1697
+ }
1698
+
1699
+ restoreState(state) {
1700
+ this._regs.set(state.regs);
1701
+ this._status = state.status;
1702
+ this._isResultReady = state.isResultReady;
1703
+ this._mmioData = state.mmioData;
1704
+ this._mmioClocks = state.mmioClocks;
1705
+ this._driveOut = state.driveOut;
1706
+ this._shiftRegister = state.shiftRegister;
1707
+ this._numShifts = state.numShifts;
1708
+ this._state = state.state;
1709
+ this._stateCount = state.stateCount;
1710
+ this._stateIsIndexPulse = state.stateIsIndexPulse;
1711
+ this._crc = state.crc;
1712
+ this._onDiscCrc = state.onDiscCrc;
1713
+ this._paramCallback = state.paramCallback;
1714
+ this._indexPulseCallback = state.indexPulseCallback;
1715
+ this._timerState = state.timerState;
1716
+ this._callContext = state.callContext;
1717
+ this._didSeekStep = state.didSeekStep;
1718
+
1719
+ // Restore drives
1720
+ for (let i = 0; i < this._drives.length; i++) {
1721
+ this._drives[i].restoreState(state.drives[i]);
1722
+ }
1723
+
1724
+ // Derive _currentDrive from _driveOut
1725
+ this._currentDrive = null;
1726
+ const selectBits = this._driveOut & DriveOut.selectFlags;
1727
+ if (selectBits === DriveOut.select_0) this._currentDrive = this._drives[0];
1728
+ else if (selectBits === DriveOut.select_1) this._currentDrive = this._drives[1];
1729
+
1730
+ // Restore timer
1731
+ this._timerTask.cancel();
1732
+ if (state.timerTaskOffset !== null) this._timerTask.schedule(state.timerTaskOffset);
1733
+
1734
+ // NMI level is saved/restored by the CPU snapshot directly,
1735
+ // so we don't reassert it here.
1736
+ }
1737
+
1673
1738
  /// jsbeeb compatibility stuff
1674
1739
  /**
1675
1740
  *
package/src/jsbeeb.css CHANGED
@@ -352,6 +352,72 @@ small {
352
352
  pointer-events: auto;
353
353
  }
354
354
 
355
+ /* Rewind scrubber panel */
356
+ #rewind-panel {
357
+ position: fixed;
358
+ bottom: 40px;
359
+ left: 0;
360
+ right: 0;
361
+ background: rgba(0, 0, 0, 0.85);
362
+ border-top: 1px solid #555;
363
+ z-index: 10;
364
+ padding: 8px 12px;
365
+ }
366
+
367
+ .rewind-header {
368
+ display: flex;
369
+ align-items: center;
370
+ gap: 12px;
371
+ margin-bottom: 6px;
372
+ }
373
+
374
+ .rewind-title {
375
+ color: #ccc;
376
+ font-size: 12px;
377
+ font-weight: bold;
378
+ }
379
+
380
+ .rewind-filmstrip {
381
+ display: flex;
382
+ gap: 6px;
383
+ overflow-x: auto;
384
+ padding: 4px 0;
385
+ }
386
+
387
+ .rewind-thumb {
388
+ flex: 0 0 auto;
389
+ cursor: pointer;
390
+ border: 2px solid transparent;
391
+ border-radius: 3px;
392
+ position: relative;
393
+ transition: border-color 0.15s;
394
+ }
395
+
396
+ .rewind-thumb:hover {
397
+ border-color: #aaa;
398
+ }
399
+
400
+ .rewind-thumb.selected {
401
+ border-color: #4af;
402
+ }
403
+
404
+ .rewind-thumb canvas {
405
+ display: block;
406
+ border-radius: 2px;
407
+ }
408
+
409
+ .rewind-thumb-label {
410
+ position: absolute;
411
+ bottom: 2px;
412
+ right: 4px;
413
+ font-size: 10px;
414
+ color: #fff;
415
+ text-shadow:
416
+ 0 0 3px #000,
417
+ 0 0 6px #000;
418
+ pointer-events: none;
419
+ }
420
+
355
421
  div.smoothie-chart-tooltip {
356
422
  background: #444;
357
423
  padding: 1em;
@@ -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
  *