jsbeeb 1.4.0 → 1.6.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/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.4.0",
10
+ "version": "1.6.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"
package/src/6502.js CHANGED
@@ -101,12 +101,12 @@ class Flags {
101
101
  }
102
102
 
103
103
  class Base6502 {
104
- constructor(model) {
104
+ constructor(model, { cycleAccurate = true } = {}) {
105
105
  this.model = model;
106
106
  this.a = this.x = this.y = this.s = 0;
107
107
  this.p = new Flags();
108
108
  this.pc = 0;
109
- this.opcodes = model.opcodesFactory(this);
109
+ this.opcodes = model.opcodesFactory(this, { cycleAccurate });
110
110
  this.disassembler = this.opcodes.disassembler;
111
111
  this.forceTracing = false;
112
112
  this.runner = this.opcodes.runInstruction;
@@ -405,9 +405,10 @@ class Base6502 {
405
405
 
406
406
  class Tube6502 extends Base6502 {
407
407
  constructor(model, cpu) {
408
- super(model);
408
+ super(model, { cycleAccurate: false });
409
409
 
410
410
  this.cycles = 0;
411
+ this.cpuMultiplier = 2;
411
412
  this.romPaged = true;
412
413
  this.memory = new Uint8Array(65536);
413
414
  this.rom = new Uint8Array(4096);
@@ -468,7 +469,7 @@ class Tube6502 extends Base6502 {
468
469
  }
469
470
 
470
471
  execute(cycles) {
471
- this.cycles += cycles * 2;
472
+ this.cycles += (cycles * this.cpuMultiplier) | 0;
472
473
  if (this.cycles < 3) return;
473
474
  while (this.cycles > 0) {
474
475
  const opcode = this.readmem(this.pc);
@@ -570,19 +571,22 @@ function is1MHzAccess(addr) {
570
571
  }
571
572
 
572
573
  export class Cpu6502 extends Base6502 {
573
- constructor(model, dbgr, video_, soundChip_, ddNoise_, music5000_, cmos, config, econet_) {
574
- super(model);
574
+ constructor(
575
+ model,
576
+ { dbgr, video, soundChip, ddNoise, music5000, cmos, config, econet, cycleAccurate = true } = {},
577
+ ) {
578
+ super(model, { cycleAccurate });
575
579
  this.config = fixUpConfig(config);
576
580
  this.debugFlags = this.config.debugFlags;
577
581
  this.cmos = cmos;
578
582
  this.debugger = dbgr;
579
583
 
580
- this.video = video_;
584
+ this.video = video;
581
585
  this.crtc = this.video.crtc;
582
586
  this.ula = this.video.ula;
583
- this.soundChip = soundChip_;
584
- this.music5000 = music5000_;
585
- this.ddNoise = ddNoise_;
587
+ this.soundChip = soundChip;
588
+ this.music5000 = music5000;
589
+ this.ddNoise = ddNoise;
586
590
  this.memStatOffsetByIFetchBank = 0;
587
591
  this.memStatOffset = 0;
588
592
  this.memStat = new Uint8Array(512);
@@ -602,8 +606,11 @@ export class Cpu6502 extends Base6502 {
602
606
  this.videoCyclesBatch = this.config.videoCyclesBatch | 0;
603
607
  this.peripheralCyclesPerSecond = 2 * 1000 * 1000;
604
608
  this.tube = model.tube ? new Tube6502(model.tube, this) : new FakeTube();
609
+ if (model.tube && this.config.tubeCpuMultiplier) {
610
+ this.tube.cpuMultiplier = this.config.tubeCpuMultiplier;
611
+ }
605
612
  this.music5000PageSel = 0;
606
- this.econet = econet_;
613
+ this.econet = econet;
607
614
 
608
615
  this.peripheralCycles = 0;
609
616
  this.videoCycles = 0;
@@ -616,16 +623,14 @@ export class Cpu6502 extends Base6502 {
616
623
  this.debugWrite = new DebugHook(this, "_debugWrite");
617
624
 
618
625
  this.scheduler = new Scheduler();
619
- this.sysvia = new via.SysVia(
620
- this,
621
- this.scheduler,
622
- this.video,
623
- this.soundChip,
624
- this.cmos,
625
- this.model.isMaster,
626
- this.config.keyLayout,
627
- this.config.getGamepads,
628
- );
626
+ this.sysvia = new via.SysVia(this, this.scheduler, {
627
+ video: this.video,
628
+ soundChip: this.soundChip,
629
+ cmos: this.cmos,
630
+ isMaster: this.model.isMaster,
631
+ initialLayout: this.config.keyLayout,
632
+ getGamepads: this.config.getGamepads,
633
+ });
629
634
  this.uservia = new via.UserVia(this, this.scheduler, this.model.isMaster, this.config.userPort);
630
635
  this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen);
631
636
  this.serial = new Serial(this.acia);
@@ -1125,6 +1130,90 @@ export class Cpu6502 extends Base6502 {
1125
1130
  this.resetLine = !resetOn;
1126
1131
  }
1127
1132
 
1133
+ snapshotState() {
1134
+ return {
1135
+ // CPU registers
1136
+ a: this.a,
1137
+ x: this.x,
1138
+ y: this.y,
1139
+ s: this.s,
1140
+ pc: this.pc,
1141
+ p: this.p.asByte(),
1142
+ nmiLevel: this._nmiLevel,
1143
+ nmiEdge: this._nmiEdge,
1144
+ halted: this.halted,
1145
+ takeInt: this.takeInt,
1146
+ // Memory control
1147
+ romsel: this.romsel,
1148
+ acccon: this.acccon,
1149
+ videoDisplayPage: this.videoDisplayPage,
1150
+ // Cycle tracking
1151
+ currentCycles: this.currentCycles,
1152
+ targetCycles: this.targetCycles,
1153
+ cycleSeconds: this.cycleSeconds,
1154
+ peripheralCycles: this.peripheralCycles,
1155
+ videoCycles: this.videoCycles,
1156
+ music5000PageSel: this.music5000PageSel,
1157
+ // RAM only (ROMs don't change at runtime)
1158
+ ram: this.ramRomOs.slice(0, this.romOffset),
1159
+ // Sub-component state
1160
+ scheduler: this.scheduler.snapshotState(),
1161
+ sysvia: this.sysvia.snapshotState(),
1162
+ uservia: this.uservia.snapshotState(),
1163
+ video: this.video.snapshotState(),
1164
+ soundChip: this.soundChip.snapshotState(),
1165
+ acia: this.acia.snapshotState(),
1166
+ adc: this.adconverter.snapshotState(),
1167
+ };
1168
+ }
1169
+
1170
+ restoreState(state) {
1171
+ // 1. Scheduler epoch first (so task offsets resolve correctly)
1172
+ this.scheduler.restoreState(state.scheduler);
1173
+
1174
+ // 2. CPU registers
1175
+ this.a = state.a;
1176
+ this.x = state.x;
1177
+ this.y = state.y;
1178
+ this.s = state.s;
1179
+ this.pc = state.pc;
1180
+ this.p.setFromByte(state.p);
1181
+ // interrupt is rebuilt by sub-component restores (VIA updateIFR, ACIA updateIrq)
1182
+ this.interrupt = 0;
1183
+ this._nmiLevel = state.nmiLevel;
1184
+ this._nmiEdge = state.nmiEdge;
1185
+ this.halted = state.halted;
1186
+ this.takeInt = state.takeInt;
1187
+
1188
+ // 3. Memory
1189
+ this.ramRomOs.set(state.ram);
1190
+ // Load ROMs if present (e.g. from BEM snapshot import)
1191
+ if (state.roms) {
1192
+ this.ramRomOs.set(state.roms.slice(0, 16 * 16384), this.romOffset);
1193
+ }
1194
+ this.videoDisplayPage = state.videoDisplayPage;
1195
+ this.music5000PageSel = state.music5000PageSel;
1196
+
1197
+ // 4. Rebuild memStat/memLook from romsel/acccon
1198
+ this.romSelect(state.romsel);
1199
+ if (this.model.isMaster) this.writeAcccon(state.acccon);
1200
+
1201
+ // 5. Cycle tracking
1202
+ this.currentCycles = state.currentCycles;
1203
+ this.targetCycles = state.targetCycles;
1204
+ this.cycleSeconds = state.cycleSeconds;
1205
+ this.peripheralCycles = state.peripheralCycles;
1206
+ this.videoCycles = state.videoCycles;
1207
+
1208
+ // 6. Sub-components (these re-register scheduler tasks)
1209
+ this.sysvia.restoreState(state.sysvia);
1210
+ this.uservia.restoreState(state.uservia);
1211
+ this.video.restoreState(state.video);
1212
+ this.soundChip.restoreState(state.soundChip);
1213
+ this.acia.restoreState(state.acia);
1214
+ this.adconverter.restoreState(state.adc);
1215
+ }
1216
+
1128
1217
  reset(hard) {
1129
1218
  if (hard) {
1130
1219
  // On the Master, opcodes executing from 0xc000 - 0xdfff can optionally have their memory accesses
@@ -41,8 +41,9 @@ function push(reg) {
41
41
  }
42
42
 
43
43
  class InstructionGen {
44
- constructor(is65c12) {
44
+ constructor(is65c12, cycleAccurate = true) {
45
45
  this.is65c12 = is65c12;
46
+ this.cycleAccurate = cycleAccurate;
46
47
  this.ops = {};
47
48
  this.cycle = 0;
48
49
  }
@@ -106,6 +107,10 @@ class InstructionGen {
106
107
  }
107
108
 
108
109
  spuriousOp(addr, reg) {
110
+ if (!this.cycleAccurate) {
111
+ this.cycle++;
112
+ return;
113
+ }
109
114
  if (this.is65c12) {
110
115
  this.readOp(addr, "", true);
111
116
  } else {
@@ -122,7 +127,7 @@ class InstructionGen {
122
127
  toSkip++;
123
128
  continue;
124
129
  }
125
- if (toSkip && this.ops[i].exact) {
130
+ if (this.cycleAccurate && toSkip && this.ops[i].exact) {
126
131
  if (this.ops[i].addr) {
127
132
  out.push(`cpu.polltimeAddr(${toSkip}, ${this.ops[i].addr});`);
128
133
  } else {
@@ -134,7 +139,7 @@ class InstructionGen {
134
139
  toSkip++;
135
140
  }
136
141
  if (toSkip) {
137
- if (this.ops[this.cycle] && this.ops[this.cycle].addr) {
142
+ if (this.cycleAccurate && this.ops[this.cycle] && this.ops[this.cycle].addr) {
138
143
  out.push(`cpu.polltimeAddr(${toSkip}, ${this.ops[this.cycle].addr});`);
139
144
  } else {
140
145
  out.push(`cpu.polltime(${toSkip});`);
@@ -145,17 +150,17 @@ class InstructionGen {
145
150
  }
146
151
 
147
152
  split(condition) {
148
- return new SplitInstruction(this, condition, this.is65c12);
153
+ return new SplitInstruction(this, condition, this.is65c12, this.cycleAccurate);
149
154
  }
150
155
  }
151
156
 
152
157
  class SplitInstruction {
153
- constructor(preamble, condition, is65c12) {
158
+ constructor(preamble, condition, is65c12, cycleAccurate = true) {
154
159
  this.preamble = preamble;
155
160
  this.condition = condition;
156
- this.ifTrue = new InstructionGen(is65c12);
161
+ this.ifTrue = new InstructionGen(is65c12, cycleAccurate);
157
162
  this.ifTrue.tick(preamble.cycle);
158
- this.ifFalse = new InstructionGen(is65c12);
163
+ this.ifFalse = new InstructionGen(is65c12, cycleAccurate);
159
164
  this.ifFalse.tick(preamble.cycle);
160
165
 
161
166
  ["append", "prepend", "readOp", "writeOp", "spuriousOp"].forEach((op) => {
@@ -1052,7 +1057,7 @@ class Disassemble6502 {
1052
1057
  }
1053
1058
  }
1054
1059
 
1055
- function makeCpuFunctions(cpu, opcodes, is65c12) {
1060
+ function makeCpuFunctions(cpu, opcodes, is65c12, cycleAccurate = true) {
1056
1061
  function getInstruction(opcodeString, needsReg) {
1057
1062
  const split = opcodeString.split(" ");
1058
1063
  const opcode = split[0];
@@ -1060,7 +1065,7 @@ function makeCpuFunctions(cpu, opcodes, is65c12) {
1060
1065
  const op = getOp(opcode, arg);
1061
1066
  if (!op) return null;
1062
1067
 
1063
- let ig = new InstructionGen(is65c12);
1068
+ let ig = new InstructionGen(is65c12, cycleAccurate);
1064
1069
  if (needsReg) ig.append("let REG = 0|0;");
1065
1070
 
1066
1071
  switch (arg) {
@@ -1398,14 +1403,14 @@ ${indent}`)
1398
1403
  };
1399
1404
  }
1400
1405
 
1401
- export function Cpu6502(cpu) {
1402
- return makeCpuFunctions(cpu, opcodes6502, false);
1406
+ export function Cpu6502(cpu, { cycleAccurate = true } = {}) {
1407
+ return makeCpuFunctions(cpu, opcodes6502, false, cycleAccurate);
1403
1408
  }
1404
1409
 
1405
- export function Cpu65c12(cpu) {
1406
- return makeCpuFunctions(cpu, opcodes65c12, true);
1410
+ export function Cpu65c12(cpu, { cycleAccurate = true } = {}) {
1411
+ return makeCpuFunctions(cpu, opcodes65c12, true, cycleAccurate);
1407
1412
  }
1408
1413
 
1409
- export function Cpu65c02(cpu) {
1410
- return makeCpuFunctions(cpu, opcodes65c02, true);
1414
+ export function Cpu65c02(cpu, { cycleAccurate = true } = {}) {
1415
+ return makeCpuFunctions(cpu, opcodes65c02, true, cycleAccurate);
1411
1416
  }
package/src/acia.js CHANGED
@@ -118,20 +118,34 @@ export class Acia {
118
118
  selectRs423(selected) {
119
119
  this.rs423Selected = !!selected;
120
120
  if (this.rs423Selected) {
121
- // RS423 selected.
122
- // CTS is always high, meaning not Clear To Send. This is
123
- // because we don't yet emulate anything on the "other end",
124
- // so there is nothing to pull CTS low.
125
- this.sr |= 0x08;
121
+ // When a handler is present it acts as the remote device and is
122
+ // always ready to receive, so CTS is low (active = clear to send).
123
+ // Without a handler there is nothing on the other end and CTS
124
+ // stays high, which inhibits TDRE so the OS output buffer drains
125
+ // silently rather than hanging.
126
+ if (this.rs423Handler) {
127
+ this.sr &= ~0x08; // CTS low — clear to send
128
+ } else {
129
+ this.sr |= 0x08; // CTS high — not connected
130
+ }
126
131
  } else {
127
- // Cassette selected.
128
- // CTS is always low, meaning actually Clear To Send.
132
+ // Cassette selected — CTS is always low (clear to send).
129
133
  this.sr &= ~0x08;
130
134
  }
131
135
  this.dcdLineUpdated();
132
136
  this.runRs423Task.ensureScheduled(this.rs423Selected, this.serialReceiveCyclesPerByte);
133
137
  }
134
138
 
139
+ /**
140
+ * Attach or detach an RS-423 peripheral handler at runtime.
141
+ * The handler must implement onTransmit(byte) and tryReceive(rts).
142
+ * If RS-423 is already selected, the CTS line is updated immediately.
143
+ */
144
+ setRs423Handler(handler) {
145
+ this.rs423Handler = handler;
146
+ if (this.rs423Selected) this.selectRs423(true);
147
+ }
148
+
135
149
  dcdLineUpdated() {
136
150
  // AUG: "It will always be low when the RS423 interface is selected".
137
151
  const level = this.rs423Selected ? false : this.tapeDcdLineLevel;
@@ -183,6 +197,48 @@ export class Acia {
183
197
  this.updateIrq();
184
198
  }
185
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
+
186
242
  setTape(tape) {
187
243
  this.tape = tape;
188
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,30 @@ 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" },
233
257
  {
234
258
  label: "Soft Reset",
235
259
  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
  });
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ // The 16 standard BBC Micro colours in ABGR format (0xffBBGGRR, little-endian canvas RGBA).
4
+ // Colours 0-7 are the primary palette; 8-15 duplicate them as the default solid/non-flash set.
5
+ // Imported by both video.js (NulaDefaultPalette) and teletext.js (BbcDefaultCollook) to avoid
6
+ // circular imports between those two modules.
7
+ export const BbcDefaultPalette = new Uint32Array([
8
+ 0xff000000, // 0: black
9
+ 0xff0000ff, // 1: red
10
+ 0xff00ff00, // 2: green
11
+ 0xff00ffff, // 3: yellow
12
+ 0xffff0000, // 4: blue
13
+ 0xffff00ff, // 5: magenta
14
+ 0xffffff00, // 6: cyan
15
+ 0xffffffff, // 7: white
16
+ 0xff000000, // 8: black (solid duplicate)
17
+ 0xff0000ff, // 9: red
18
+ 0xff00ff00, // 10: green
19
+ 0xff00ffff, // 11: yellow
20
+ 0xffff0000, // 12: blue
21
+ 0xffff00ff, // 13: magenta
22
+ 0xffffff00, // 14: cyan
23
+ 0xffffffff, // 15: white
24
+ ]);