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/README.md CHANGED
@@ -10,6 +10,8 @@ and a 128K BBC Master, along with a number of different peripherals.
10
10
  ## Table of Contents
11
11
 
12
12
  - [Keyboard Mappings](#keyboard-mappings)
13
+ - [Emulator Shortcuts](#emulator-shortcuts)
14
+ - [Save State and Rewind](#save-state-and-rewind)
13
15
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
14
16
  - [Running as a Desktop Application](#running-as-a-desktop-application)
15
17
  - [URL Parameters](#url-parameters)
@@ -33,6 +35,26 @@ The BBC had a somewhat different-looking keyboard to a modern PC, and so it's us
33
35
  To play right now, visit [https://bbc.xania.org/](https://bbc.xania.org/). To load the default disc image (Elite in this
34
36
  case), press shift-F12 (which is shift-Break on the BBC).
35
37
 
38
+ ### Emulator Shortcuts
39
+
40
+ | Shortcut | Action |
41
+ | -------------- | ------------------------------- |
42
+ | `Ctrl+Home` | Stop and enter debugger |
43
+ | `Ctrl+Insert` | Toggle turbo (fast-as-possible) |
44
+ | `Ctrl+End` | Pause emulation |
45
+ | `Alt+PageDown` | Open rewind scrubber |
46
+
47
+ ### Save State and Rewind
48
+
49
+ Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S` / `Ctrl+O` in the Electron app).
50
+
51
+ The emulator continuously captures snapshots into a 30-slot rewind buffer (~1 per second). Open the rewind scrubber from **State > Rewind** or press **Alt+PageDown** to browse recent states as a visual filmstrip:
52
+
53
+ - **Left/Right arrows** — navigate between snapshots (the main screen updates live)
54
+ - **Click** a thumbnail to jump to that point
55
+ - **Enter** — commit selection and close the panel
56
+ - **Escape** — cancel and restore the original state
57
+
36
58
  ### Joystick Support
37
59
 
38
60
  jsbeeb supports both USB/Bluetooth gamepads and mouse-based analogue joystick emulation. Note that BBC Micro joysticks use inverted axes:
@@ -184,8 +206,6 @@ If you're looking to help:
184
206
  - Play lots of games and report issues either on [GitHub](https://github.com/mattgodbolt/jsbeeb/issues) or by email (
185
207
  matt@godbolt.org).
186
208
  - Core
187
- - Save state ability
188
- - Once we have this I'd love to get some "reverse step" debugging support
189
209
  - Get the "boo" of the boot "boo-beep" working (disabled currently as the JavaScript startup makes the sound
190
210
  dreadfully choppy on Chrome at least).
191
211
  - Save disc support
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.6.0",
10
+ "version": "1.8.0",
11
11
  "//": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": "22"
@@ -34,8 +34,8 @@
34
34
  "fflate": "^0.8.2",
35
35
  "jquery": "^4.0.0",
36
36
  "pako": "^2.1.0",
37
- "smoothie": "^1.36.1",
38
37
  "sharp": "^0.34.5",
38
+ "smoothie": "^1.36.1",
39
39
  "underscore": "^1.13.7"
40
40
  },
41
41
  "devDependencies": {
@@ -46,6 +46,7 @@
46
46
  "eslint-plugin-prettier": "^5.5.5",
47
47
  "globals": "^17.3.0",
48
48
  "husky": "^9.1.7",
49
+ "jsdom": "^29.0.0",
49
50
  "lint-staged": "^16.2.7",
50
51
  "npm-run-all2": "^8.0.4",
51
52
  "pixelmatch": "^7.1.0",
package/src/6502.js CHANGED
@@ -616,6 +616,7 @@ export class Cpu6502 extends Base6502 {
616
616
  this.videoCycles = 0;
617
617
 
618
618
  this.polltime = this.buildPolltime();
619
+ this.is1MHzAccess = this.buildIs1MHzAccess();
619
620
 
620
621
  this._debugRead = this._debugWrite = this._debugInstruction = null;
621
622
  this.debugInstruction = new DebugHook(this, "_debugInstruction");
@@ -1164,6 +1165,7 @@ export class Cpu6502 extends Base6502 {
1164
1165
  soundChip: this.soundChip.snapshotState(),
1165
1166
  acia: this.acia.snapshotState(),
1166
1167
  adc: this.adconverter.snapshotState(),
1168
+ fdc: this.fdc.snapshotState(),
1167
1169
  };
1168
1170
  }
1169
1171
 
@@ -1212,6 +1214,11 @@ export class Cpu6502 extends Base6502 {
1212
1214
  this.soundChip.restoreState(state.soundChip);
1213
1215
  this.acia.restoreState(state.acia);
1214
1216
  this.adconverter.restoreState(state.adc);
1217
+
1218
+ // FDC state (v2+). If absent (v1 snapshot), FDC keeps its current state.
1219
+ if (state.fdc) {
1220
+ this.fdc.restoreState(state.fdc);
1221
+ }
1215
1222
  }
1216
1223
 
1217
1224
  reset(hard) {
@@ -1275,6 +1282,7 @@ export class Cpu6502 extends Base6502 {
1275
1282
  this._nmiEdge = false;
1276
1283
  this._nmiLevel = false;
1277
1284
  this.halted = false;
1285
+ this.breakpointResume = false;
1278
1286
  this.music5000PageSel = 0;
1279
1287
  this.video.reset(this, this.sysvia, hard);
1280
1288
  this.soundChip.reset(hard);
@@ -1290,15 +1298,30 @@ export class Cpu6502 extends Base6502 {
1290
1298
  this.sysvia.setKeyLayout(this.config.keyLayout);
1291
1299
  }
1292
1300
 
1293
- polltimeAddr(cycles, addr) {
1301
+ polltimeAddr(cycles, addr, isWrite) {
1294
1302
  cycles = cycles | 0;
1295
- if (is1MHzAccess(addr)) {
1303
+ if (this.is1MHzAccess(addr, isWrite)) {
1296
1304
  cycles += 1 + ((cycles ^ this.currentCycles) & 1);
1297
1305
  }
1298
1306
  this.polltime(cycles);
1299
1307
  }
1300
1308
 
1301
- // Builds commong code between polltimeSlow and polltimeFast
1309
+ // Bake in the 1MHz bus check at construction time. On the Master, ACCCON
1310
+ // TST (bit 6) remaps FRED, JIM, and SHEILA reads (&FC00-&FEFF) to the
1311
+ // internal 2MHz bus, but writes still go through the external 1MHz bus.
1312
+ // Non-Master machines just use the static address check with no ACCCON
1313
+ // awareness.
1314
+ buildIs1MHzAccess() {
1315
+ if (this.model.isMaster) {
1316
+ return (addr, isWrite) => {
1317
+ if (!isWrite && this.acccon & 0x40 && addr >= 0xfc00 && addr < 0xff00) return false;
1318
+ return is1MHzAccess(addr);
1319
+ };
1320
+ }
1321
+ return is1MHzAccess;
1322
+ }
1323
+
1324
+ // Builds common code between polltimeSlow and polltimeFast
1302
1325
  buildPolltime() {
1303
1326
  const nop = (_cycles) => {};
1304
1327
  const tubeStuff = (cycles) => (this.model.tube ? this.tube.execute(cycles) : nop);
@@ -1373,16 +1396,22 @@ export class Cpu6502 extends Base6502 {
1373
1396
  }
1374
1397
 
1375
1398
  executeInternal() {
1376
- let first = true;
1399
+ // Skip the debug-instruction check on the very first instruction only
1400
+ // when resuming from a previous breakpoint stop (so we don't immediately
1401
+ // re-trigger the same breakpoint). Previously this skipped the first
1402
+ // instruction of every execute() chunk, which could miss breakpoints.
1403
+ let skipNext = this.breakpointResume;
1404
+ this.breakpointResume = false;
1377
1405
  while (!this.halted && this.currentCycles < this.targetCycles) {
1378
1406
  this.oldPcIndex = (this.oldPcIndex + 1) & 0xff;
1379
1407
  this.oldPcArray[this.oldPcIndex] = this.pc;
1380
1408
  this.memStatOffset = this.memStatOffsetByIFetchBank & (1 << (this.pc >>> 12)) ? 256 : 0;
1381
1409
  const opcode = this.readmem(this.pc);
1382
- if (this._debugInstruction && !first && this._debugInstruction(this.pc, opcode)) {
1410
+ if (this._debugInstruction && !skipNext && this._debugInstruction(this.pc, opcode)) {
1411
+ this.breakpointResume = true;
1383
1412
  return false;
1384
1413
  }
1385
- first = false;
1414
+ skipNext = false;
1386
1415
  this.incpc();
1387
1416
  this.runner.run(opcode);
1388
1417
  this.oldAArray[this.oldPcIndex] = this.a;
@@ -88,6 +88,7 @@ class InstructionGen {
88
88
  let op = `cpu.writemem(${addr}, ${reg});`;
89
89
  if (spurious) op += " // spurious";
90
90
  this.append(this.cycle, op, true, addr);
91
+ this.ops[this.cycle].isWrite = true;
91
92
  }
92
93
 
93
94
  zpReadOp(addr, reg) {
@@ -129,7 +130,8 @@ class InstructionGen {
129
130
  }
130
131
  if (this.cycleAccurate && toSkip && this.ops[i].exact) {
131
132
  if (this.ops[i].addr) {
132
- out.push(`cpu.polltimeAddr(${toSkip}, ${this.ops[i].addr});`);
133
+ const isWrite = this.ops[i].isWrite ? "true" : "false";
134
+ out.push(`cpu.polltimeAddr(${toSkip}, ${this.ops[i].addr}, ${isWrite});`);
133
135
  } else {
134
136
  out.push(`cpu.polltime(${toSkip});`);
135
137
  }
@@ -140,7 +142,8 @@ class InstructionGen {
140
142
  }
141
143
  if (toSkip) {
142
144
  if (this.cycleAccurate && this.ops[this.cycle] && this.ops[this.cycle].addr) {
143
- out.push(`cpu.polltimeAddr(${toSkip}, ${this.ops[this.cycle].addr});`);
145
+ const isWrite = this.ops[this.cycle].isWrite ? "true" : "false";
146
+ out.push(`cpu.polltimeAddr(${toSkip}, ${this.ops[this.cycle].addr}, ${isWrite});`);
144
147
  } else {
145
148
  out.push(`cpu.polltime(${toSkip});`);
146
149
  }
@@ -1151,7 +1154,9 @@ function makeCpuFunctions(cpu, opcodes, is65c12, cycleAccurate = true) {
1151
1154
  // so I read the non-carry here.
1152
1155
  if (!op.rotate) ig.ifFalse.readOp("addrNonCarry", "REG");
1153
1156
  ig.readOp("addrWithCarry", "REG");
1154
- ig.writeOp("addrWithCarry", "REG");
1157
+ // 65c12 does "two reads and one write" for RMW (not the 6502's
1158
+ // "one read and two writes"). The spurious cycle is a read.
1159
+ ig.readOp("addrWithCarry", "", true);
1155
1160
  } else {
1156
1161
  // For RMW we always have a spurious read and then a spurious read or write
1157
1162
  ig.readOp("addrNonCarry");
@@ -1236,11 +1241,19 @@ function makeCpuFunctions(cpu, opcodes, is65c12, cycleAccurate = true) {
1236
1241
  ig.readOp("addrWithCarry", "REG");
1237
1242
  ig.spuriousOp("addrWithCarry", "REG");
1238
1243
  } else if (op.write) {
1239
- // Pure stores still exhibit a read at the non-carried address.
1240
- ig.readOp("addrNonCarry");
1241
- if (op.zpQuirk) {
1242
- // with this quirk on undocumented instructions, a page crossing writes to 00XX
1243
- ig.append("if (addrWithCarry !== addrNonCarry) addrWithCarry &= 0xff;");
1244
+ if (is65c12) {
1245
+ // On the 65c12, the dead cycle reads the Previous Bus Address (PBA)
1246
+ // rather than the partially-formed or target address. Since PBA is
1247
+ // always a zero-page or instruction-fetch address (2MHz), it never
1248
+ // triggers 1MHz bus stretching. We model this as tick(1).
1249
+ ig.tick(1);
1250
+ } else {
1251
+ // Pure stores still exhibit a read at the non-carried address.
1252
+ ig.readOp("addrNonCarry");
1253
+ if (op.zpQuirk) {
1254
+ // with this quirk on undocumented instructions, a page crossing writes to 00XX
1255
+ ig.append("if (addrWithCarry !== addrNonCarry) addrWithCarry &= 0xff;");
1256
+ }
1244
1257
  }
1245
1258
  }
1246
1259
  ig.append(op.op);
package/src/app/app.js CHANGED
@@ -254,6 +254,11 @@ const template = [
254
254
  },
255
255
  },
256
256
  { type: "separator" },
257
+ {
258
+ label: "Rewind...",
259
+ click: sendAction("rewind"),
260
+ },
261
+ { type: "separator" },
257
262
  {
258
263
  label: "Soft Reset",
259
264
  click: sendAction("soft-reset"),
package/src/disc-drive.js CHANGED
@@ -368,4 +368,37 @@ export class DiscDrive extends BaseDiscDrive {
368
368
  _checkTrackNeedsWrite() {
369
369
  if (this.disc) this.disc.flushWrites();
370
370
  }
371
+
372
+ snapshotState() {
373
+ return {
374
+ track: this._track,
375
+ isSideUpper: this._isSideUpper,
376
+ headPosition: this._headPosition,
377
+ pulsePosition: this._pulsePosition,
378
+ in32usMode: this._in32usMode,
379
+ spinning: this._spinning,
380
+ is40Track: this._is40Track,
381
+ timerTaskOffset: this._timer.scheduled() ? this._timer.expireEpoch - this._scheduler.epoch : null,
382
+ disc: this._disc ? this._disc.snapshotState() : null,
383
+ };
384
+ }
385
+
386
+ restoreState(state) {
387
+ this._track = state.track;
388
+ this._isSideUpper = state.isSideUpper;
389
+ this._headPosition = state.headPosition;
390
+ this._pulsePosition = state.pulsePosition;
391
+ this._in32usMode = state.in32usMode;
392
+ this._is40Track = state.is40Track;
393
+
394
+ // Restore spinning state and timer
395
+ this._timer.cancel();
396
+ this._spinning = state.spinning;
397
+ if (state.timerTaskOffset !== null) this._timer.schedule(state.timerTaskOffset);
398
+
399
+ // Restore disc data if present
400
+ if (state.disc && this._disc) {
401
+ this._disc.restoreState(state.disc);
402
+ }
403
+ }
371
404
  }
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;