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/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.5.0",
10
+ "version": "1.7.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");
@@ -1130,6 +1131,96 @@ export class Cpu6502 extends Base6502 {
1130
1131
  this.resetLine = !resetOn;
1131
1132
  }
1132
1133
 
1134
+ snapshotState() {
1135
+ return {
1136
+ // CPU registers
1137
+ a: this.a,
1138
+ x: this.x,
1139
+ y: this.y,
1140
+ s: this.s,
1141
+ pc: this.pc,
1142
+ p: this.p.asByte(),
1143
+ nmiLevel: this._nmiLevel,
1144
+ nmiEdge: this._nmiEdge,
1145
+ halted: this.halted,
1146
+ takeInt: this.takeInt,
1147
+ // Memory control
1148
+ romsel: this.romsel,
1149
+ acccon: this.acccon,
1150
+ videoDisplayPage: this.videoDisplayPage,
1151
+ // Cycle tracking
1152
+ currentCycles: this.currentCycles,
1153
+ targetCycles: this.targetCycles,
1154
+ cycleSeconds: this.cycleSeconds,
1155
+ peripheralCycles: this.peripheralCycles,
1156
+ videoCycles: this.videoCycles,
1157
+ music5000PageSel: this.music5000PageSel,
1158
+ // RAM only (ROMs don't change at runtime)
1159
+ ram: this.ramRomOs.slice(0, this.romOffset),
1160
+ // Sub-component state
1161
+ scheduler: this.scheduler.snapshotState(),
1162
+ sysvia: this.sysvia.snapshotState(),
1163
+ uservia: this.uservia.snapshotState(),
1164
+ video: this.video.snapshotState(),
1165
+ soundChip: this.soundChip.snapshotState(),
1166
+ acia: this.acia.snapshotState(),
1167
+ adc: this.adconverter.snapshotState(),
1168
+ fdc: this.fdc.snapshotState(),
1169
+ };
1170
+ }
1171
+
1172
+ restoreState(state) {
1173
+ // 1. Scheduler epoch first (so task offsets resolve correctly)
1174
+ this.scheduler.restoreState(state.scheduler);
1175
+
1176
+ // 2. CPU registers
1177
+ this.a = state.a;
1178
+ this.x = state.x;
1179
+ this.y = state.y;
1180
+ this.s = state.s;
1181
+ this.pc = state.pc;
1182
+ this.p.setFromByte(state.p);
1183
+ // interrupt is rebuilt by sub-component restores (VIA updateIFR, ACIA updateIrq)
1184
+ this.interrupt = 0;
1185
+ this._nmiLevel = state.nmiLevel;
1186
+ this._nmiEdge = state.nmiEdge;
1187
+ this.halted = state.halted;
1188
+ this.takeInt = state.takeInt;
1189
+
1190
+ // 3. Memory
1191
+ this.ramRomOs.set(state.ram);
1192
+ // Load ROMs if present (e.g. from BEM snapshot import)
1193
+ if (state.roms) {
1194
+ this.ramRomOs.set(state.roms.slice(0, 16 * 16384), this.romOffset);
1195
+ }
1196
+ this.videoDisplayPage = state.videoDisplayPage;
1197
+ this.music5000PageSel = state.music5000PageSel;
1198
+
1199
+ // 4. Rebuild memStat/memLook from romsel/acccon
1200
+ this.romSelect(state.romsel);
1201
+ if (this.model.isMaster) this.writeAcccon(state.acccon);
1202
+
1203
+ // 5. Cycle tracking
1204
+ this.currentCycles = state.currentCycles;
1205
+ this.targetCycles = state.targetCycles;
1206
+ this.cycleSeconds = state.cycleSeconds;
1207
+ this.peripheralCycles = state.peripheralCycles;
1208
+ this.videoCycles = state.videoCycles;
1209
+
1210
+ // 6. Sub-components (these re-register scheduler tasks)
1211
+ this.sysvia.restoreState(state.sysvia);
1212
+ this.uservia.restoreState(state.uservia);
1213
+ this.video.restoreState(state.video);
1214
+ this.soundChip.restoreState(state.soundChip);
1215
+ this.acia.restoreState(state.acia);
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
+ }
1222
+ }
1223
+
1133
1224
  reset(hard) {
1134
1225
  if (hard) {
1135
1226
  // On the Master, opcodes executing from 0xc000 - 0xdfff can optionally have their memory accesses
@@ -1191,6 +1282,7 @@ export class Cpu6502 extends Base6502 {
1191
1282
  this._nmiEdge = false;
1192
1283
  this._nmiLevel = false;
1193
1284
  this.halted = false;
1285
+ this.breakpointResume = false;
1194
1286
  this.music5000PageSel = 0;
1195
1287
  this.video.reset(this, this.sysvia, hard);
1196
1288
  this.soundChip.reset(hard);
@@ -1206,15 +1298,30 @@ export class Cpu6502 extends Base6502 {
1206
1298
  this.sysvia.setKeyLayout(this.config.keyLayout);
1207
1299
  }
1208
1300
 
1209
- polltimeAddr(cycles, addr) {
1301
+ polltimeAddr(cycles, addr, isWrite) {
1210
1302
  cycles = cycles | 0;
1211
- if (is1MHzAccess(addr)) {
1303
+ if (this.is1MHzAccess(addr, isWrite)) {
1212
1304
  cycles += 1 + ((cycles ^ this.currentCycles) & 1);
1213
1305
  }
1214
1306
  this.polltime(cycles);
1215
1307
  }
1216
1308
 
1217
- // 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
1218
1325
  buildPolltime() {
1219
1326
  const nop = (_cycles) => {};
1220
1327
  const tubeStuff = (cycles) => (this.model.tube ? this.tube.execute(cycles) : nop);
@@ -1289,16 +1396,22 @@ export class Cpu6502 extends Base6502 {
1289
1396
  }
1290
1397
 
1291
1398
  executeInternal() {
1292
- 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;
1293
1405
  while (!this.halted && this.currentCycles < this.targetCycles) {
1294
1406
  this.oldPcIndex = (this.oldPcIndex + 1) & 0xff;
1295
1407
  this.oldPcArray[this.oldPcIndex] = this.pc;
1296
1408
  this.memStatOffset = this.memStatOffsetByIFetchBank & (1 << (this.pc >>> 12)) ? 256 : 0;
1297
1409
  const opcode = this.readmem(this.pc);
1298
- if (this._debugInstruction && !first && this._debugInstruction(this.pc, opcode)) {
1410
+ if (this._debugInstruction && !skipNext && this._debugInstruction(this.pc, opcode)) {
1411
+ this.breakpointResume = true;
1299
1412
  return false;
1300
1413
  }
1301
- first = false;
1414
+ skipNext = false;
1302
1415
  this.incpc();
1303
1416
  this.runner.run(opcode);
1304
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/acia.js CHANGED
@@ -197,6 +197,48 @@ export class Acia {
197
197
  this.updateIrq();
198
198
  }
199
199
 
200
+ snapshotState() {
201
+ const scheduler = this.txCompleteTask.scheduler;
202
+ return {
203
+ sr: this.sr,
204
+ cr: this.cr,
205
+ dr: this.dr,
206
+ rs423Selected: this.rs423Selected,
207
+ motorOn: this.motorOn,
208
+ tapeCarrierCount: this.tapeCarrierCount,
209
+ tapeDcdLineLevel: this.tapeDcdLineLevel,
210
+ hadDcdHigh: this.hadDcdHigh,
211
+ serialReceiveRate: this.serialReceiveRate,
212
+ serialReceiveCyclesPerByte: this.serialReceiveCyclesPerByte,
213
+ txCompleteTaskOffset: this.txCompleteTask.scheduled()
214
+ ? this.txCompleteTask.expireEpoch - scheduler.epoch
215
+ : null,
216
+ runTapeTaskOffset: this.runTapeTask.scheduled() ? this.runTapeTask.expireEpoch - scheduler.epoch : null,
217
+ runRs423TaskOffset: this.runRs423Task.scheduled() ? this.runRs423Task.expireEpoch - scheduler.epoch : null,
218
+ };
219
+ }
220
+
221
+ restoreState(state) {
222
+ this.sr = state.sr;
223
+ this.cr = state.cr;
224
+ this.dr = state.dr;
225
+ this.rs423Selected = state.rs423Selected;
226
+ this.motorOn = state.motorOn;
227
+ this.tapeCarrierCount = state.tapeCarrierCount;
228
+ this.tapeDcdLineLevel = state.tapeDcdLineLevel;
229
+ this.hadDcdHigh = state.hadDcdHigh;
230
+ this.serialReceiveRate = state.serialReceiveRate;
231
+ this.serialReceiveCyclesPerByte = state.serialReceiveCyclesPerByte;
232
+ this.updateIrq();
233
+
234
+ this.txCompleteTask.cancel();
235
+ this.runTapeTask.cancel();
236
+ this.runRs423Task.cancel();
237
+ if (state.txCompleteTaskOffset !== null) this.txCompleteTask.schedule(state.txCompleteTaskOffset);
238
+ if (state.runTapeTaskOffset !== null) this.runTapeTask.schedule(state.runTapeTaskOffset);
239
+ if (state.runRs423TaskOffset !== null) this.runRs423Task.schedule(state.runRs423TaskOffset);
240
+ }
241
+
200
242
  setTape(tape) {
201
243
  this.tape = tape;
202
244
  }
package/src/adc.js CHANGED
@@ -99,6 +99,28 @@ export class Adc {
99
99
  }
100
100
  }
101
101
 
102
+ snapshotState() {
103
+ const scheduler = this.task.scheduler;
104
+ return {
105
+ status: this.status,
106
+ low: this.low,
107
+ high: this.high,
108
+ taskOffset: this.task.scheduled() ? this.task.expireEpoch - scheduler.epoch : null,
109
+ };
110
+ }
111
+
112
+ restoreState(state) {
113
+ this.status = state.status;
114
+ this.low = state.low;
115
+ this.high = state.high;
116
+ this.task.cancel();
117
+ if (state.taskOffset !== null) {
118
+ this.task.schedule(state.taskOffset);
119
+ // Conversion in progress: CB1 should be high (set by write(), cleared by onComplete())
120
+ this.sysvia.setcb1(true);
121
+ }
122
+ }
123
+
102
124
  /**
103
125
  * Read from the ADC registers
104
126
  * @param {number} addr - The address to read from
package/src/app/app.js CHANGED
@@ -230,6 +230,35 @@ const template = [
230
230
  click: showModal("configuration"),
231
231
  },
232
232
  { type: "separator" },
233
+ {
234
+ label: "Save State...",
235
+ accelerator: "CmdOrCtrl+S",
236
+ click: sendAction("save-state"),
237
+ },
238
+ {
239
+ label: "Load State...",
240
+ accelerator: "CmdOrCtrl+O",
241
+ click: async (_, browserWindow) => {
242
+ const result = await dialog.showOpenDialog(browserWindow, {
243
+ title: "Load emulator state",
244
+ filters: [
245
+ { name: "Snapshot files", extensions: ["gz", "json", "snp"] },
246
+ { name: "All files", extensions: ["*"] },
247
+ ],
248
+ properties: ["openFile"],
249
+ });
250
+ if (!result.canceled) {
251
+ const filePath = getFileParam(result.filePaths[0]);
252
+ browserWindow.webContents.send("load-state", { path: filePath });
253
+ }
254
+ },
255
+ },
256
+ { type: "separator" },
257
+ {
258
+ label: "Rewind...",
259
+ click: sendAction("rewind"),
260
+ },
261
+ { type: "separator" },
233
262
  {
234
263
  label: "Soft Reset",
235
264
  click: sendAction("soft-reset"),
@@ -6,7 +6,7 @@
6
6
  export let initialise = function () {};
7
7
 
8
8
  function init(args) {
9
- const { loadDiscImage, loadTapeImage, processor, modals, actions, config } = args;
9
+ const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config } = args;
10
10
  const api = window.electronAPI;
11
11
 
12
12
  api.onLoadDisc(async (message) => {
@@ -34,6 +34,15 @@ function init(args) {
34
34
  }
35
35
  });
36
36
 
37
+ api.onLoadState(async (message) => {
38
+ if (loadStateFile) {
39
+ const response = await fetch(message.path);
40
+ const blob = await response.blob();
41
+ const file = new File([blob], message.path.split("/").pop());
42
+ await loadStateFile(file);
43
+ }
44
+ });
45
+
37
46
  // Observe model name changes and update window title
38
47
  const modelElement = document.querySelector(".bbc-model");
39
48
  if (modelElement) {
@@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
7
7
  onLoadTape: (callback) => ipcRenderer.on("load-tape", (event, message) => callback(message)),
8
8
  onShowModal: (callback) => ipcRenderer.on("show-modal", (event, message) => callback(message)),
9
9
  onAction: (callback) => ipcRenderer.on("action", (event, message) => callback(message)),
10
+ onLoadState: (callback) => ipcRenderer.on("load-state", (event, message) => callback(message)),
10
11
  setTitle: (title) => ipcRenderer.send("set-title", title),
11
12
  saveSettings: (settings) => ipcRenderer.send("save-settings", settings),
12
13
  });