jsbeeb 1.22.3 → 1.22.4

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.22.3",
10
+ "version": "1.22.4",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
package/src/6502.js CHANGED
@@ -1195,6 +1195,7 @@ export class Cpu6502 extends Base6502 {
1195
1195
  while (this.model.swram[romIndex]) {
1196
1196
  romIndex--;
1197
1197
  }
1198
+ if (romIndex < 0) throw new Error("Too many extra ROMs (no free sideways ROM banks)");
1198
1199
 
1199
1200
  awaiting.push(this.loadRom(extraRoms[i_2], this.romOffset + romIndex * 16384));
1200
1201
  }
@@ -1823,7 +1824,11 @@ export class AtomCpu6502 extends Cpu6502 {
1823
1824
 
1824
1825
  // Override loadRom to accept 4KB ROMs
1825
1826
  async loadRom(name, offset) {
1826
- const data = await utils.loadData("roms/" + name);
1827
+ if (name.indexOf("http") !== 0) name = "roms/" + name;
1828
+ let data = await utils.loadData(name);
1829
+ if (/\.zip/i.test(name)) {
1830
+ data = (await utils.unzipRomImage(data)).data;
1831
+ }
1827
1832
  const len = data.length;
1828
1833
  if (len !== 16384 && len !== 8192 && len !== 4096) {
1829
1834
  throw new Error(`Broken ROM file ${name} (length=${len})`);
package/src/cmos.js CHANGED
@@ -9,11 +9,11 @@
9
9
  // STNICC-beeb to hang on the Master 128 because the WD1770 seek overran the
10
10
  // vsync window. Confirmed by @tom-seddon (b2 emulator):
11
11
  // https://github.com/mattgodbolt/jsbeeb/issues/576
12
- const defaultCmos = [
12
+ const defaultCmos = Object.freeze([
13
13
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x00, 0xeb, 0x00,
14
14
  0xc9, 0xff, 0xff, 0x12, 0x00, 0x17, 0xc8, 0x1e, 0x05, 0x00, 0x35, 0xa6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
15
15
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
16
- ];
16
+ ]);
17
17
 
18
18
  let timeOffset = 0;
19
19
 
@@ -82,7 +82,7 @@ export class Cmos {
82
82
  this.cmosAddr = 0;
83
83
 
84
84
  if (!this.store) {
85
- this.store = defaultCmos;
85
+ this.store = [...defaultCmos];
86
86
  }
87
87
  if (cmosOverride) {
88
88
  this.store = cmosOverride(this.store);
package/src/main.js CHANGED
@@ -50,6 +50,7 @@ let secondDiscImage = null;
50
50
  const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
51
51
  if (queryDiscImage) discImage = queryDiscImage;
52
52
  if (querySecondDisc) secondDiscImage = querySecondDisc;
53
+ const defaultBootDisc = queryDiscImage ? undefined : discImage;
53
54
  const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
54
55
 
55
56
  const extraRoms = [];
@@ -276,6 +277,7 @@ const snapshots = new SnapshotUI({
276
277
  urlState,
277
278
  modals,
278
279
  loop,
280
+ defaultBootDisc,
279
281
  });
280
282
 
281
283
  const inputs = new AnalogueInputs({
@@ -392,6 +394,7 @@ document.getElementById("soft-reset").addEventListener("click", function (event)
392
394
  });
393
395
 
394
396
  document.getElementById("download-filestore-link").addEventListener("click", function () {
397
+ if (!processor.filestore) return;
395
398
  downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
396
399
  });
397
400
 
package/src/web/debug.js CHANGED
@@ -89,6 +89,48 @@ class MemoryView {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * Guesses the address of the instruction before the given one by scoring each
94
+ * possible run of instructions leading up to it: a point per "common"
95
+ * instruction (loads, stores, branches, compares, arithmetic and
96
+ * carry-set/clear that avoid unusual indexing modes) and a large boost for a
97
+ * run that passes through the current program counter. The highest-scoring
98
+ * run wins and its last instruction is the answer.
99
+ * Good test cases:
100
+ * Repton 2 @ 2cbb
101
+ * MOS @ cfc8
102
+ * also, just starting from the back of ROM and going up...
103
+ */
104
+ export function prevInstruction(disassemble, pc, address) {
105
+ const commonInstructions =
106
+ /(RTS|B..|JMP|JSR|LD[AXY]|ST[AXY]|TA[XY]|T[XY]A|AD[DC]|SUB|SBC|CLC|SEC|CMP|EOR|ORR|AND|INC|DEC).*/;
107
+ const uncommonInstrucions = /.*,\s*([XY]|X\))$/;
108
+
109
+ address &= 0xffff;
110
+ let bestAddr = address - 1;
111
+ let bestScore = 0;
112
+ for (let startingPoint = address - 20; startingPoint !== address; startingPoint++) {
113
+ let score = 0;
114
+ let addr = startingPoint & 0xffff;
115
+ while (addr < address) {
116
+ const result = disassemble(addr);
117
+ if (addr === pc) score += 10;
118
+ if (result[0].match(commonInstructions) && !result[0].match(uncommonInstrucions)) {
119
+ score++;
120
+ }
121
+ if (result[1] === address) {
122
+ if (score > bestScore) {
123
+ bestScore = score;
124
+ bestAddr = addr;
125
+ break;
126
+ }
127
+ }
128
+ addr = result[1];
129
+ }
130
+ }
131
+ return bestAddr;
132
+ }
133
+
92
134
  export class Debugger {
93
135
  constructor() {
94
136
  this.patchInstructions = new Map();
@@ -382,42 +424,7 @@ export class Debugger {
382
424
  }
383
425
 
384
426
  prevInstruction(address) {
385
- // Some attempt at making prevInstruction more accurate; score the sequence of instructions leading
386
- // up to the target by counting all "common" instructions as a point. The highest-scoring run of
387
- // instructions is picked as the most likely, and the previous from that is used. Common instructions
388
- // here mean loads, stores, branches, compares, arithmetic and carry-set/clear that don't use "unusual"
389
- // indexing modes like abs,X, abs,Y and (zp,X).
390
- // Good test cases:
391
- // Repton 2 @ 2cbb
392
- // MOS @ cfc8
393
- // also, just starting from the back of ROM and going up...
394
- const commonInstructions =
395
- /(RTS|B..|JMP|JSR|LD[AXY]|ST[AXY]|TA[XY]|T[XY]A|AD[DC]|SUB|SBC|CLC|SEC|CMP|EOR|ORR|AND|INC|DEC).*/;
396
- const uncommonInstrucions = /.*,\s*([XY]|X\))$/;
397
-
398
- address &= 0xffff;
399
- let bestAddr = address - 1;
400
- let bestScore = 0;
401
- for (let startingPoint = address - 20; startingPoint !== address; startingPoint++) {
402
- let score = 0;
403
- let addr = startingPoint & 0xffff;
404
- while (addr < address) {
405
- const result = this.disassemble(addr);
406
- if (result[0] === this.cpu.pc) score += 10; // huge boost if this instruction was executed
407
- if (result[0].match(commonInstructions) && !result[0].match(uncommonInstrucions)) {
408
- score++;
409
- }
410
- if (result[1] === address) {
411
- if (score > bestScore) {
412
- bestScore = score;
413
- bestAddr = addr;
414
- break;
415
- }
416
- }
417
- addr = result[1];
418
- }
419
- }
420
- return bestAddr;
427
+ return prevInstruction((addr) => this.disassemble(addr), this.cpu.pc, address);
421
428
  }
422
429
 
423
430
  updatePrevMem() {
@@ -28,10 +28,13 @@ export class GoogleDrivePicker {
28
28
  this.authEl.style.display = "none";
29
29
  e.preventDefault();
30
30
  const authed = await this.auth(false);
31
- if (authed) this.authResolve();
32
- else this.authReject(new Error("Unable to authorize Google Drive"));
31
+ if (authed) this.authResolve?.(true);
32
+ else this.authReject?.(new Error("Unable to authorize Google Drive"));
33
33
  });
34
34
 
35
+ // Closing the loading dialog while the auth panel waits is a cancellation, not an error.
36
+ document.getElementById("loading-dialog").addEventListener("hidden.bs.modal", () => this.authResolve?.(false));
37
+
35
38
  // Loading the Google client holds the main thread for ~100ms, so it waits for
36
39
  // someone to ask for Drive.
37
40
  document.getElementById("open-drive-link").addEventListener("click", async (e) => {
@@ -74,11 +77,21 @@ export class GoogleDrivePicker {
74
77
  console.log("Google Drive authed=", authed);
75
78
 
76
79
  if (!authed) {
77
- await new Promise((resolve, reject) => {
78
- this.authResolve = resolve;
79
- this.authReject = reject;
80
- this.authEl.style.display = "";
81
- });
80
+ let granted;
81
+ try {
82
+ granted = await new Promise((resolve, reject) => {
83
+ this.authResolve = resolve;
84
+ this.authReject = reject;
85
+ this.authEl.style.display = "";
86
+ });
87
+ } finally {
88
+ this.authResolve = this.authReject = null;
89
+ }
90
+ if (!granted) {
91
+ console.log("Google Drive authorization dismissed");
92
+ this.modals.loadingFinished();
93
+ return null;
94
+ }
82
95
  }
83
96
 
84
97
  const ssd = await this.googleDrive.load(this.processor.fdc, cat.id, layout);
@@ -93,7 +106,8 @@ export class GoogleDrivePicker {
93
106
  return ssd;
94
107
  } catch (error) {
95
108
  console.error("Google Drive loading error:", error);
96
- this.modals.loadingFinished(`Unable to load ${cat.name} from Google Drive: ${errorText(error)}`);
109
+ this.modals.loadingFinished();
110
+ throw error;
97
111
  }
98
112
  }
99
113
 
@@ -122,8 +136,14 @@ export class GoogleDrivePicker {
122
136
  utils.noteEvent("google-drive", "click", item.name);
123
137
  this.media.setDisc1Image(`gd:${item.id}/${item.name}`);
124
138
  this.modal.hide();
125
- const ssd = await this.load(item, this.drives.layoutForDrive(0));
126
- if (ssd) this.drives.putDiscIn(0, ssd);
139
+ try {
140
+ const ssd = await this.load(item, this.drives.layoutForDrive(0));
141
+ if (ssd) this.drives.putDiscIn(0, ssd);
142
+ } catch (error) {
143
+ toast(`Unable to load ${item.name} from Google Drive: ${errorText(error)}`, {
144
+ title: "Google Drive",
145
+ });
146
+ }
127
147
  });
128
148
  }
129
149
  }
@@ -162,15 +162,17 @@ export class Machine {
162
162
  }
163
163
 
164
164
  if (discImage) {
165
- startImageLoad(`disc ${discImage}`, async () =>
166
- drives.putDiscIn(0, await media.loadDiscImage(discImage, drives.layoutForDrive(0))),
167
- );
165
+ startImageLoad(`disc ${discImage}`, async () => {
166
+ const loadedDisc = await media.loadDiscImage(discImage, drives.layoutForDrive(0));
167
+ if (loadedDisc) drives.putDiscIn(0, loadedDisc);
168
+ });
168
169
  }
169
170
 
170
171
  if (secondDiscImage) {
171
- startImageLoad(`disc ${secondDiscImage}`, async () =>
172
- drives.putDiscIn(1, await media.loadDiscImage(secondDiscImage, drives.layoutForDrive(1))),
173
- );
172
+ startImageLoad(`disc ${secondDiscImage}`, async () => {
173
+ const loadedDisc = await media.loadDiscImage(secondDiscImage, drives.layoutForDrive(1));
174
+ if (loadedDisc) drives.putDiscIn(1, loadedDisc);
175
+ });
174
176
  }
175
177
 
176
178
  if (tape) {
@@ -210,8 +210,9 @@ export class MediaLoader extends EventTarget {
210
210
  }
211
211
 
212
212
  async loadSCSIFile(file) {
213
- const binaryData = await readFileAsBinaryString(file);
214
213
  const { processor } = this;
214
+ if (!processor.filestore) return;
215
+ const binaryData = await readFileAsBinaryString(file);
215
216
  processor.filestore.scsi = utils.stringToUint8Array(binaryData);
216
217
 
217
218
  processor.filestore.PC = 0x400;
@@ -1,7 +1,6 @@
1
1
  import * as disc from "../fdc.js";
2
2
  import { DiscLayout } from "../disc.js";
3
3
  import { downloadBlob } from "../dom-utils.js";
4
- import { toast } from "./toast.js";
5
4
  import {
6
5
  createSnapshot,
7
6
  restoreSnapshot,
@@ -58,9 +57,14 @@ async function readSnapshot(arrayBuffer) {
58
57
  * where they have one, the image bytes where they do not, and CRCs for
59
58
  * saying when a source has changed underneath a state.
60
59
  */
61
- export function snapshotMedia(fdcDrives, params) {
60
+ export function snapshotMedia(fdcDrives, params, defaultBootDisc) {
62
61
  const manifest = {};
62
+ const drive0Disc = fdcDrives[0].disc;
63
63
  if (params.disc1 || params.disc) manifest.disc1 = params.disc1 || params.disc;
64
+ // A default boot loads the built-in disc without naming it in the URL; record it by
65
+ // name so the state can reload it, but only while that disc is still in the drive.
66
+ else if (defaultBootDisc && drive0Disc && drive0Disc.name === defaultBootDisc && !drive0Disc.originalImageData)
67
+ manifest.disc1 = defaultBootDisc;
64
68
  if (params.disc2) manifest.disc2 = params.disc2;
65
69
 
66
70
  // For each drive with a disc loaded, include CRC32 for verification
@@ -84,7 +88,7 @@ export function snapshotMedia(fdcDrives, params) {
84
88
 
85
89
  /** Saving and restoring states: the menu item, the file input and the reload across a model change. */
86
90
  export class SnapshotUI {
87
- constructor({ processor, model, video, media, drives, urlState, modals, loop }) {
91
+ constructor({ processor, model, video, media, drives, urlState, modals, loop, defaultBootDisc }) {
88
92
  this.processor = processor;
89
93
  this.model = model;
90
94
  this.video = video;
@@ -93,6 +97,7 @@ export class SnapshotUI {
93
97
  this.urlState = urlState;
94
98
  this.modals = modals;
95
99
  this.loop = loop;
100
+ this.defaultBootDisc = defaultBootDisc;
96
101
 
97
102
  document.getElementById("save-state").addEventListener("click", async (event) => {
98
103
  event.preventDefault();
@@ -110,22 +115,27 @@ export class SnapshotUI {
110
115
  async saveState() {
111
116
  const wasRunning = this.loop.isRunning();
112
117
  if (wasRunning) this.loop.stop(false);
118
+ let failure = null;
113
119
  try {
114
- const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params);
120
+ const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params, this.defaultBootDisc);
115
121
  const snapshot = createSnapshot(this.processor, this.model, manifest);
116
122
  const json = snapshotToJSON(snapshot);
117
123
  const blob = await compressBlob(new Blob([json]));
118
124
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
119
125
  downloadBlob(blob, `jsbeeb-${this.model.name}-${timestamp}.json.gz`);
120
126
  } catch (e) {
121
- this.modals.showError("saving state", e);
127
+ failure = e;
122
128
  }
129
+ // Resume before reporting failure: the error dialog pauses a running
130
+ // loop itself, and resumes it on close.
123
131
  if (wasRunning) this.loop.go();
132
+ if (failure) this.modals.showError("saving state", failure);
124
133
  }
125
134
 
126
135
  async loadStateFromFile(file, preReadBuffer) {
127
136
  const wasRunning = this.loop.isRunning();
128
137
  if (wasRunning) this.loop.stop(false);
138
+ let failure = null;
129
139
  try {
130
140
  const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
131
141
  const snapshot = await readSnapshot(arrayBuffer);
@@ -142,9 +152,10 @@ export class SnapshotUI {
142
152
  // Force a repaint so the display updates even while paused
143
153
  this.video.paint();
144
154
  } catch (e) {
145
- this.modals.showError("loading state", e);
155
+ failure = e;
146
156
  }
147
157
  if (wasRunning) this.loop.go();
158
+ if (failure) this.modals.showError("loading state", failure);
148
159
  }
149
160
 
150
161
  /** Picks up the state a cross-model reload stashed, once the matching machine is up. */
@@ -192,16 +203,38 @@ export class SnapshotUI {
192
203
  // Retain the image bytes so subsequent saves can re-embed them.
193
204
  loadedDisc.setOriginalImage(imageData);
194
205
  }
195
- if (!loadedDisc) continue;
196
-
197
- // Verify CRC32 if present
198
- if (savedMedia[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
199
- if (loadedDisc.originalImageCrc32 !== savedMedia[crcKey]) {
200
- toast(
201
- `${loadedDisc.name} has changed since this state was saved. The state has been restored anyway and may not run correctly.`,
202
- { title: "Restoring state" },
206
+ if (!loadedDisc) {
207
+ if (savedMedia[crcKey] != null) {
208
+ // A state may name no source (older default-boot saves); the disc
209
+ // already in the drive can still satisfy the CRC, but only laid out
210
+ // the way the state's dirty tracks expect.
211
+ const currentDisc = this.processor.fdc.drives[driveIndex].disc;
212
+ const currentLayout = currentDisc?.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
213
+ if (
214
+ currentDisc &&
215
+ currentDisc.originalImageCrc32 === savedMedia[crcKey] &&
216
+ currentLayout === layout
217
+ )
218
+ continue;
219
+ const problem = savedMedia[discKey]
220
+ ? `The disc for drive ${driveIndex} (${savedMedia[discKey]}) could not be reloaded`
221
+ : `This state does not record where the disc in drive ${driveIndex} came from`;
222
+ throw new Error(
223
+ `${problem}, and the drive does not hold a matching disc. ` +
224
+ `Load the right disc, then load the state again.`,
203
225
  );
204
226
  }
227
+ continue;
228
+ }
229
+
230
+ if (
231
+ savedMedia[crcKey] != null &&
232
+ loadedDisc.originalImageCrc32 != null &&
233
+ loadedDisc.originalImageCrc32 !== savedMedia[crcKey]
234
+ ) {
235
+ throw new Error(
236
+ `${loadedDisc.name} has changed since this state was saved, so the state cannot be restored over it.`,
237
+ );
205
238
  }
206
239
 
207
240
  this.drives.putDiscIn(driveIndex, loadedDisc);