jsbeeb 1.5.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 +1 -1
- package/src/6502.js +84 -0
- package/src/acia.js +42 -0
- package/src/adc.js +22 -0
- package/src/app/app.js +24 -0
- package/src/app/electron.js +10 -1
- package/src/app/preload.js +1 -0
- package/src/bem-snapshot.js +681 -0
- package/src/machine-session.js +91 -0
- package/src/main.js +137 -1
- package/src/rewind.js +71 -0
- package/src/scheduler.js +28 -0
- package/src/snapshot.js +110 -0
- package/src/soundchip.js +39 -0
- package/src/state-utils.js +66 -0
- package/src/teletext.js +69 -0
- package/src/via.js +91 -0
- package/src/video.js +143 -0
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.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
|
@@ -1130,6 +1130,90 @@ export class Cpu6502 extends Base6502 {
|
|
|
1130
1130
|
this.resetLine = !resetOn;
|
|
1131
1131
|
}
|
|
1132
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
|
+
|
|
1133
1217
|
reset(hard) {
|
|
1134
1218
|
if (hard) {
|
|
1135
1219
|
// On the Master, opcodes executing from 0xc000 - 0xdfff can optionally have their memory accesses
|
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,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"),
|
package/src/app/electron.js
CHANGED
|
@@ -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) {
|
package/src/app/preload.js
CHANGED
|
@@ -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
|
});
|