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 +22 -2
- package/package.json +3 -2
- package/src/6502.js +35 -6
- package/src/6502.opcodes.js +21 -8
- package/src/app/app.js +5 -0
- package/src/disc-drive.js +33 -0
- package/src/disc.js +176 -22
- package/src/fdc.js +3 -1
- package/src/intel-fdc.js +65 -0
- package/src/jsbeeb.css +66 -0
- package/src/main.js +120 -31
- package/src/rewind-thumbnail.js +118 -0
- package/src/rewind-ui.js +230 -0
- package/src/rewind.js +13 -0
- package/src/snapshot.js +38 -5
- package/src/soundchip.js +1 -5
- package/src/wd-fdc.js +99 -2
- package/tests/test-machine.js +189 -146
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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 && !
|
|
1410
|
+
if (this._debugInstruction && !skipNext && this._debugInstruction(this.pc, opcode)) {
|
|
1411
|
+
this.breakpointResume = true;
|
|
1383
1412
|
return false;
|
|
1384
1413
|
}
|
|
1385
|
-
|
|
1414
|
+
skipNext = false;
|
|
1386
1415
|
this.incpc();
|
|
1387
1416
|
this.runner.run(opcode);
|
|
1388
1417
|
this.oldAArray[this.oldPcIndex] = this.a;
|
package/src/6502.opcodes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
//
|
|
1243
|
-
|
|
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
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
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|