jsbeeb 1.22.2 → 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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  [![jsbeeb tests](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml/badge.svg)](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml)
2
- [![coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmattgodbolt%2Fjsbeeb%2Fbadges%2Fcoverage.json)](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml)
2
+ [![coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fbbc.xania.org%2Fcoverage%2Fbadge.json)](https://bbc.xania.org/coverage/index.html)
3
3
 
4
4
  # jsbeeb - JavaScript BBC Micro Emulator
5
5
 
@@ -16,6 +16,7 @@ different peripherals.
16
16
  - [Emulator Shortcuts](#emulator-shortcuts)
17
17
  - [Printer Output](#printer-output)
18
18
  - [Save State and Rewind](#save-state-and-rewind)
19
+ - [Joystick Support](#joystick-support)
19
20
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
20
21
  - [Running as a Desktop Application](#running-as-a-desktop-application)
21
22
  - [URL Parameters](#url-parameters)
@@ -120,10 +121,10 @@ Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S`
120
121
 
121
122
  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:
122
123
 
123
- - **Left/Right arrows** navigate between snapshots (the main screen updates live)
124
+ - **Left/Right arrows**: navigate between snapshots (the main screen updates live)
124
125
  - **Click** a thumbnail to jump to that point
125
- - **Enter** commit selection and close the panel
126
- - **Escape** cancel and restore the original state
126
+ - **Enter**: commit selection and close the panel
127
+ - **Escape**: cancel and restore the original state
127
128
 
128
129
  ### Joystick Support
129
130
 
@@ -146,13 +147,13 @@ presses `SPACE`. To play Superior's Space Invaders on a pad, where `COPY` fires:
146
147
 
147
148
  The gamepad control names are:
148
149
 
149
- - `FIRE` every button at once, which is usually what you want for a one-button game
150
- - `UP` `DOWN` `LEFT` `RIGHT` both analogue sticks at once, plus one face button each (`UP` is also `A`, `DOWN` is
150
+ - `FIRE`: every button at once, which is usually what you want for a one-button game
151
+ - `UP` `DOWN` `LEFT` `RIGHT`: both analogue sticks at once, plus one face button each (`UP` is also `A`, `DOWN` is
151
152
  `X`, `LEFT` is `Y`, `RIGHT` is `B`)
152
- - `UP1` `DOWN1` `LEFT1` `RIGHT1` the left stick only; `UP2` `DOWN2` the right stick only; `UP3` `DOWN3` … — the
153
- face buttons only
154
- - `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT` individual buttons, by their Xbox 360 names
155
- - `FIRE1` `FIRE2` clicking the left and right sticks
153
+ - `UP1` `DOWN1` `LEFT1` `RIGHT1`: the left stick only; `UP2` `DOWN2` and so on: the right stick only; `UP3` `DOWN3`
154
+ and so on: the face buttons only
155
+ - `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT`: individual buttons, by their Xbox 360 names
156
+ - `FIRE1` `FIRE2`: clicking the left and right sticks
156
157
 
157
158
  The BBC key names are the same as for the keyboard, and digits may be written either way round: `GP.A=1` and `GP.A=K1`
158
159
  both press `1`. Unlike `KEY.`, gamepad mappings are BBC-only: there's no Atom equivalent. The D-pad's default mapping
@@ -191,7 +192,9 @@ site to make it smaller and faster to load when it's deployed to [https://bbc.xa
191
192
 
192
193
  ## Running as a Desktop Application
193
194
 
194
- jsbeeb can also run as a standalone desktop application using Electron:
195
+ jsbeeb can also run as a standalone desktop application using Electron. Prebuilt packages (Debian/Ubuntu `.deb`,
196
+ Fedora/RHEL `.rpm` and a Windows installer) are attached to each
197
+ [GitHub release](https://github.com/mattgodbolt/jsbeeb/releases), or you can build your own:
195
198
 
196
199
  ### Running in Development
197
200
 
@@ -235,17 +238,15 @@ USE_SYSTEM_FPM=true npm run electron:build
235
238
  **Debian/Ubuntu:**
236
239
 
237
240
  ```sh
238
- sudo apt install ./out/dist/jsbeeb_1.0.1_amd64.deb
241
+ sudo apt install ./out/dist/jsbeeb_<version>_amd64.deb
239
242
  ```
240
243
 
241
244
  **Fedora/RHEL/CentOS:**
242
245
 
243
246
  ```sh
244
- sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
247
+ sudo rpm -i out/dist/jsbeeb-<version>.x86_64.rpm
245
248
  ```
246
249
 
247
- **Note:** Electron support was re-enabled in November 2024 after being disabled during the ESM migration in 2021. It now works with Electron 28+ which added full ES Modules support.
248
-
249
250
  ## URL Parameters
250
251
 
251
252
  - `autoboot` - fakes a shift break
@@ -335,19 +336,11 @@ Note that every update you make means you need to make a new raw link.
335
336
 
336
337
  If you're looking to help:
337
338
 
338
- - Testing
339
- - Play lots of games and report issues either on [GitHub](https://github.com/mattgodbolt/jsbeeb/issues) or by email (
340
- matt@godbolt.org).
341
- - Core
342
- - Get the "boo" of the boot "boo-beep" working (disabled currently as the JavaScript startup makes the sound
343
- dreadfully choppy on Chrome at least).
344
- - Save disc support
345
- - Local discs need to be made more workable and need an "export" feature
346
- - Multiple discs need a UI
347
- - `git grep -i todo`
348
- - Optimisation
349
- - While every attempt to make things fast has been made, I'm sure there's some more clever things that can be done
350
- without compromising emulation accuracy
339
+ - Play lots of games and report anything that doesn't behave like the real machine, either on
340
+ [GitHub](https://github.com/mattgodbolt/jsbeeb/issues) or by email (matt@godbolt.org).
341
+ - Pick something from the [issue tracker](https://github.com/mattgodbolt/jsbeeb/issues): there's a mix of emulation
342
+ accuracy work, missing hardware and peripherals, user interface improvements and performance ideas in there, at all
343
+ levels of difficulty.
351
344
 
352
345
  ## Tests
353
346
 
@@ -370,12 +363,14 @@ For timing correctness, we have:
370
363
  correctness-sensitive when it comes to the timers and interrupts of the BBC.
371
364
  - Some 65C12-specific read-modify-write tests written by Ed Spittles.
372
365
 
373
- Tests can be run automatically if you have `node` installed - just run `make` and it'll ensure the relevant libraries
374
- are installed, then it'll run the tests. Please note it can take a while to run the whole test suite.
366
+ Tests are run via npm scripts: `npm test` runs the whole suite. The vitest suites are `npm run test:unit`,
367
+ `npm run test:integration` and `npm run test:shader`; CPU tests are `npm run test:cpu`, and the smoke test is
368
+ `npm run test:smoke` (the shader and smoke tests need Chrome installed). Please note it can take a while to run the
369
+ whole test suite.
375
370
 
376
371
  ## Thanks
377
372
 
378
- jsbeeb was heavily based on Sarah Walker's C [B-Em emulator](https://github.com/stardot/b-em) -- thanks to her for her
373
+ jsbeeb was heavily based on Sarah Walker's C [B-Em emulator](https://github.com/stardot/b-em); thanks to her for her
379
374
  hard work and for open sourcing her code. B-em is now being maintained by a group of enthusiasts - thanks to them too!
380
375
 
381
376
  Huge thanks to Richard Talbot-Watkins for his advice and help along the way in fathoming out the instruction timings,
@@ -410,7 +405,8 @@ and posted the [video up on YouTube](https://www.youtube.com/watch?v=37jyHQT7fXQ
410
405
 
411
406
  ## License
412
407
 
413
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
408
+ This project is licensed under the GNU General Public License v3.0 (or later); see the [LICENSE](LICENSE) file for
409
+ details.
414
410
 
415
411
  ## Contact
416
412
 
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.2",
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})`);
@@ -4,7 +4,7 @@
4
4
  // Handles IPC communication for loading disc/tape images and showing modals from Electron's main process.
5
5
 
6
6
  function init(args) {
7
- const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config } = args;
7
+ const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config, media } = args;
8
8
  const api = window.electronAPI;
9
9
 
10
10
  api.onLoadDisc(async (message) => {
@@ -58,7 +58,9 @@ function init(args) {
58
58
  config.addEventListener("settings-changed", (e) => {
59
59
  api.saveSettings(e.detail);
60
60
  });
61
- config.addEventListener("media-changed", (e) => {
61
+ }
62
+ if (media) {
63
+ media.addEventListener("media-changed", (e) => {
62
64
  api.saveSettings(e.detail);
63
65
  });
64
66
  }
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
@@ -22,6 +22,7 @@ import { Display } from "./web/display.js";
22
22
  import { Layout } from "./web/layout.js";
23
23
  import { EmulationLoop, RewindCaptureInterval } from "./web/emulation-loop.js";
24
24
  import { KeyboardSetup } from "./web/keyboard-setup.js";
25
+ import { AccessibilitySwitches } from "./web/accessibility-switches.js";
25
26
  import { AnalogueInputs } from "./web/analogue-inputs.js";
26
27
  import { FrontPanel } from "./web/front-panel.js";
27
28
  import { Machine } from "./web/machine.js";
@@ -49,6 +50,7 @@ let secondDiscImage = null;
49
50
  const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
50
51
  if (queryDiscImage) discImage = queryDiscImage;
51
52
  if (querySecondDisc) secondDiscImage = querySecondDisc;
53
+ const defaultBootDisc = queryDiscImage ? undefined : discImage;
52
54
  const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
53
55
 
54
56
  const extraRoms = [];
@@ -92,19 +94,7 @@ const printer = new Printer({
92
94
  }),
93
95
  });
94
96
 
95
- const keys = new KeyboardSetup({
96
- enterDebugger: () => loop.stop(true),
97
- reload: () => window.location.reload(),
98
- toggleFast: () => loop.toggleFastAsPossible(),
99
- openRewind: () => rewindUI.open(),
100
- openPrinter: () => frontPanel.checkPrinterWindow(),
101
- pause: () => loop.stop(false),
102
- resume: () => loop.go(),
103
- onAnyKeyDown: () => {
104
- audioHandler.tryResume();
105
- inputs.ensureMicrophoneRunning();
106
- },
107
- });
97
+ const accessibilitySwitches = new AccessibilitySwitches();
108
98
 
109
99
  const settings = new Settings({ urlState });
110
100
  const { config, speechOutput, keyLayout, displayMode, audioOutput, speakerAmount } = settings;
@@ -188,7 +178,7 @@ const machine = new Machine({
188
178
  cpuMultiplier,
189
179
  extraRoms,
190
180
  stationId,
191
- userPort: keys.userPort,
181
+ userPort: accessibilitySwitches.userPort,
192
182
  printer,
193
183
  speechOutput,
194
184
  video,
@@ -197,6 +187,26 @@ const machine = new Machine({
197
187
  });
198
188
  const processor = machine.processor;
199
189
 
190
+ const keys = new KeyboardSetup({
191
+ actions: {
192
+ enterDebugger: () => loop.stop(true),
193
+ reload: () => window.location.reload(),
194
+ toggleFast: () => loop.toggleFastAsPossible(),
195
+ openRewind: () => rewindUI.open(),
196
+ openPrinter: () => frontPanel.checkPrinterWindow(),
197
+ pause: () => loop.stop(false),
198
+ resume: () => loop.go(),
199
+ onAnyKeyDown: () => {
200
+ audioHandler.tryResume();
201
+ inputs.ensureMicrophoneRunning();
202
+ },
203
+ },
204
+ accessibilitySwitches,
205
+ processor,
206
+ dbgr,
207
+ keyLayout,
208
+ });
209
+
200
210
  const rewindBuffer = new RewindBuffer(30);
201
211
  const loop = new EmulationLoop({
202
212
  processor,
@@ -225,23 +235,15 @@ loop.addEventListener("running", () => {
225
235
 
226
236
  const modals = new Modals({ loop });
227
237
 
228
- const drives = new Drives({ fdc: processor.fdc, driveTracks, areYouSure: modals.areYouSure.bind(modals) });
238
+ const drives = new Drives({ fdc: processor.fdc, driveTracks, confirm: modals.confirm.bind(modals) });
229
239
  const media = new MediaLoader({
230
240
  processor,
231
241
  model,
232
242
  drives,
233
243
  urlState,
234
- config,
235
244
  modals,
236
245
  isSnapshotFile,
237
246
  loadSnapshot: (file, buffer) => snapshots.loadStateFromFile(file, buffer),
238
- // The archives are created further down; each source resolves when used.
239
- sources: {
240
- sth: (name) => sthPicker.discs.fetch(name),
241
- tapeSth: (name) => sthPicker.tapes.fetch(name),
242
- hfe: (path) => hfePicker.archive.fetch(path),
243
- drive: (cat, layout) => drivePicker.load(cat, layout),
244
- },
245
247
  });
246
248
  const autoBoot = new Autoboot({
247
249
  model,
@@ -257,7 +259,7 @@ const sthPicker = new SthPicker({
257
259
  processor,
258
260
  autoboot: (image) => autoBoot.boot(image),
259
261
  });
260
- const hfePicker = new HfePicker({
262
+ new HfePicker({
261
263
  media,
262
264
  drives,
263
265
  modals,
@@ -265,7 +267,7 @@ const hfePicker = new HfePicker({
265
267
  processor,
266
268
  autoboot: (image) => autoBoot.boot(image),
267
269
  });
268
- const drivePicker = new GoogleDrivePicker({ media, drives, modals, processor });
270
+ new GoogleDrivePicker({ media, drives, modals, processor });
269
271
  const snapshots = new SnapshotUI({
270
272
  processor,
271
273
  model,
@@ -275,6 +277,7 @@ const snapshots = new SnapshotUI({
275
277
  urlState,
276
278
  modals,
277
279
  loop,
280
+ defaultBootDisc,
278
281
  });
279
282
 
280
283
  const inputs = new AnalogueInputs({
@@ -295,8 +298,6 @@ if (parsedQuery.microphoneChannel !== undefined) {
295
298
  // Apply ADC source settings from URL parameters
296
299
  inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
297
300
 
298
- keys.attach({ processor, dbgr, keyLayout });
299
-
300
301
  const frontPanel = new FrontPanel({ processor, model, printer });
301
302
 
302
303
  // ------------------------------------------------------------------------
@@ -315,7 +316,7 @@ rewindUI.updateButtonState();
315
316
  if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
316
317
  else document.getElementById("disc-visualiser-open").classList.add("disabled");
317
318
 
318
- new Layout({
319
+ const layout = new Layout({
319
320
  screenCanvas,
320
321
  display,
321
322
  embed: parsedQuery.embed !== undefined,
@@ -323,7 +324,7 @@ new Layout({
323
324
  });
324
325
 
325
326
  // Everything a setting can reach now exists.
326
- settings.wire({ audioHandler, display, quickSettings, machine, keys, inputs, modals });
327
+ settings.wire({ audioHandler, display, layout, quickSettings, machine, keys, inputs, modals });
327
328
 
328
329
  // ------------------------------------------------------------------------
329
330
  // The page's own handlers.
@@ -393,6 +394,7 @@ document.getElementById("soft-reset").addEventListener("click", function (event)
393
394
  });
394
395
 
395
396
  document.getElementById("download-filestore-link").addEventListener("click", function () {
397
+ if (!processor.filestore) return;
396
398
  downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
397
399
  });
398
400
 
@@ -500,6 +502,7 @@ electron({
500
502
  loadTapeImage: media.loadTapeImage.bind(media),
501
503
  processor,
502
504
  config,
505
+ media,
503
506
  modals: {
504
507
  show: (modalId, sthType) => {
505
508
  if (modalId === "sth" && sthType) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The accessibility switches on the user port. Bits 0-7 correspond to switches
3
+ * 1-8, active low: 0xff = no switches pressed; a clear bit = that switch held.
4
+ *
5
+ * On real hardware, the Brilliant Computing switch interface box and special-ed
6
+ * joystick connect to the User Port only; they do not touch the analogue port
7
+ * or the System VIA fire buttons (PB4/PB5), which belong to the standard
8
+ * analogue joystick connector.
9
+ */
10
+ export class AccessibilitySwitches {
11
+ constructor() {
12
+ this.switchState = 0xff;
13
+ const switches = this;
14
+ this.userPort = {
15
+ write() {},
16
+ read() {
17
+ return switches.switchState;
18
+ },
19
+ };
20
+ }
21
+
22
+ setSwitch(index, held) {
23
+ if (held) this.switchState &= ~(1 << index);
24
+ else this.switchState |= 1 << index;
25
+ }
26
+ }
@@ -90,8 +90,7 @@ export class AnalogueInputs {
90
90
 
91
91
  clearMicrophoneChannel() {
92
92
  this.config.setMicrophoneChannel(undefined);
93
- delete this.urlState.params.microphoneChannel;
94
- this.urlState.updateUrl();
93
+ this.urlState.set({ microphoneChannel: undefined });
95
94
  }
96
95
 
97
96
  async ensureMicrophoneRunning() {
@@ -28,12 +28,7 @@ export class AutobootTicks {
28
28
  for (const check of this.checks) {
29
29
  check.addEventListener("click", () => {
30
30
  this.show(check.checked);
31
- if (check.checked) {
32
- urlState.params.autoboot = "";
33
- } else {
34
- delete urlState.params.autoboot;
35
- }
36
- urlState.updateUrl();
31
+ urlState.set({ autoboot: check.checked ? true : undefined });
37
32
  });
38
33
  }
39
34
  }
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() {
@@ -544,7 +551,7 @@ export class Debugger {
544
551
  case "N":
545
552
  this.updatePrevMem();
546
553
  this.cpu.execute(1);
547
- self.debug(this.cpu.pc);
554
+ this.debug(this.cpu.pc);
548
555
  break;
549
556
  case "m":
550
557
  this.stepOver();
@@ -111,9 +111,6 @@ export class Display {
111
111
  this.sizeCanvasFor(this.filterClass);
112
112
  this.video.paint();
113
113
  this.setCrtPic();
114
- window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
115
- // Relayout now as well: the monitor picture may have changed shape.
116
- window.dispatchEvent(new Event("resize"));
117
114
  }
118
115
 
119
116
  sizeCanvasFor(filterClass) {
package/src/web/drives.js CHANGED
@@ -11,7 +11,7 @@ const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
11
11
  * switches on the Discs menu, and downloading what is in drive 0.
12
12
  */
13
13
  export class Drives {
14
- constructor({ fdc, driveTracks, areYouSure }) {
14
+ constructor({ fdc, driveTracks, confirm }) {
15
15
  this.fdc = fdc;
16
16
  this.driveTracks = driveTracks;
17
17
  this.saidWritesAreNotKept = false;
@@ -33,7 +33,7 @@ export class Drives {
33
33
  if (drive) this.showDriveTracks(driveIndex);
34
34
  }
35
35
 
36
- document.getElementById("download-drive-link").addEventListener("click", () => {
36
+ document.getElementById("download-drive-link").addEventListener("click", async () => {
37
37
  const disc = this.discToDownload();
38
38
  if (!disc) return;
39
39
  const save = (options) =>
@@ -41,9 +41,8 @@ export class Drives {
41
41
  try {
42
42
  save();
43
43
  } catch (e) {
44
- areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
45
- save({ force: true }),
46
- );
44
+ if (await confirm(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel"))
45
+ save({ force: true });
47
46
  }
48
47
  });
49
48
 
@@ -16,6 +16,7 @@ export class GoogleDrivePicker {
16
16
  this.modals = modals;
17
17
  this.processor = processor;
18
18
  this.googleDrive = loader;
19
+ media.addSource("drive", (cat, layout) => this.load(cat, layout));
19
20
 
20
21
  this.authEl = document.getElementById("google-drive-auth");
21
22
  this.el = document.getElementById("google-drive");
@@ -27,10 +28,13 @@ export class GoogleDrivePicker {
27
28
  this.authEl.style.display = "none";
28
29
  e.preventDefault();
29
30
  const authed = await this.auth(false);
30
- if (authed) this.authResolve();
31
- 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"));
32
33
  });
33
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
+
34
38
  // Loading the Google client holds the main thread for ~100ms, so it waits for
35
39
  // someone to ask for Drive.
36
40
  document.getElementById("open-drive-link").addEventListener("click", async (e) => {
@@ -73,11 +77,21 @@ export class GoogleDrivePicker {
73
77
  console.log("Google Drive authed=", authed);
74
78
 
75
79
  if (!authed) {
76
- await new Promise((resolve, reject) => {
77
- this.authResolve = resolve;
78
- this.authReject = reject;
79
- this.authEl.style.display = "";
80
- });
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
+ }
81
95
  }
82
96
 
83
97
  const ssd = await this.googleDrive.load(this.processor.fdc, cat.id, layout);
@@ -92,7 +106,8 @@ export class GoogleDrivePicker {
92
106
  return ssd;
93
107
  } catch (error) {
94
108
  console.error("Google Drive loading error:", error);
95
- this.modals.loadingFinished(`Unable to load ${cat.name} from Google Drive: ${errorText(error)}`);
109
+ this.modals.loadingFinished();
110
+ throw error;
96
111
  }
97
112
  }
98
113
 
@@ -121,8 +136,14 @@ export class GoogleDrivePicker {
121
136
  utils.noteEvent("google-drive", "click", item.name);
122
137
  this.media.setDisc1Image(`gd:${item.id}/${item.name}`);
123
138
  this.modal.hide();
124
- const ssd = await this.load(item, this.drives.layoutForDrive(0));
125
- 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
+ }
126
147
  });
127
148
  }
128
149
  }
@@ -41,6 +41,7 @@ export class HfePicker {
41
41
  showArchiveMessage("hfe", "hfe-list", "There was an error accessing the HFE archive");
42
42
  },
43
43
  );
44
+ media.addSource("hfe", (path) => this.archive.fetch(path));
44
45
 
45
46
  this.modal = new bootstrap.Modal(document.getElementById("hfe"));
46
47
  document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
@@ -2,36 +2,16 @@ import * as utils from "../utils.js";
2
2
  import { Keyboard } from "../keyboard.js";
3
3
  import { showNotice } from "./reporting.js";
4
4
 
5
- /**
6
- * The page's keyboard: the emulated one, the browser shortcuts around it, and
7
- * the accessibility switches on the user port. Built in two steps because the
8
- * user port has to exist before the processor does, and the keyboard after.
9
- */
5
+ /** The page's keyboard: the emulated one and the browser shortcuts around it. */
10
6
  export class KeyboardSetup {
11
7
  /**
12
- * @param {object} actions what each shortcut does, supplied late-bound:
8
+ * @param {object} opts
9
+ * @param {object} opts.actions what each shortcut does, supplied late-bound:
13
10
  * enterDebugger, reload, toggleFast, openRewind, openPrinter,
14
11
  * pause, resume, onAnyKeyDown
12
+ * @param {import("./accessibility-switches.js").AccessibilitySwitches} opts.accessibilitySwitches
15
13
  */
16
- constructor(actions) {
17
- this.actions = actions;
18
- this.keyboard = null;
19
-
20
- // Accessibility switch state: bits 0-7 correspond to switches 1-8.
21
- // Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
22
- this.switchState = 0xff;
23
- const setup = this;
24
- this.userPort = {
25
- write() {},
26
- read() {
27
- return setup.switchState;
28
- },
29
- };
30
- }
31
-
32
- /** Initialise keyboard now that processor exists */
33
- attach({ processor, dbgr, keyLayout }) {
34
- const { actions } = this;
14
+ constructor({ actions, accessibilitySwitches, processor, dbgr, keyLayout }) {
35
15
  const keyboard = (this.keyboard = new Keyboard({
36
16
  processor,
37
17
  inputEnabledFunction: () => document.activeElement && document.activeElement.id === "paste-text",
@@ -66,22 +46,11 @@ export class KeyboardSetup {
66
46
  keyboard.registerKeyHandler(utils.keyCodes.PAGEDOWN, onDown("pagedown", actions.openRewind), alt);
67
47
  keyboard.registerKeyHandler(utils.keyCodes.B, onDown(null, actions.openPrinter), ctrl);
68
48
 
69
- // Register accessibility switch key handlers.
70
- // Keys 1-8 (K1-K8) and function keys F1-F8 both map to user port bits 0-7
71
- // (active low: pressing the key clears the corresponding bit in &FE60).
72
- //
73
- // On real hardware, the Brilliant Computing switch interface box and special-ed
74
- // joystick connect to the User Port only; they do not touch the analogue port
75
- // or the System VIA fire buttons (PB4/PB5), which belong to the standard
76
- // analogue joystick connector. So we only update switchState here.
77
- const handleSwitch = (bit) => (down) => {
78
- if (down) this.switchState &= ~(1 << bit);
79
- else this.switchState |= 1 << bit;
80
- };
81
-
82
- // Alt+1-8 and Alt+F1-F8 trigger the switches. Using Alt means the underlying
83
- // key is never forwarded to the BBC Micro (keyboard.js bails out early when a
84
- // handler fires), so typing numbers or using function keys works normally.
49
+ // Alt+1-8 and Alt+F1-F8 trigger the accessibility switches. Using Alt means
50
+ // the underlying key is never forwarded to the BBC Micro (keyboard.js bails
51
+ // out early when a handler fires), so typing numbers or using function keys
52
+ // works normally.
53
+ const handleSwitch = (index) => (down) => accessibilitySwitches.setSwitch(index, down);
85
54
  for (let i = 0; i < 8; i++) {
86
55
  keyboard.registerKeyHandler(utils.keyCodes.K1 + i, handleSwitch(i), alt);
87
56
  keyboard.registerKeyHandler(utils.keyCodes.F1 + i, handleSwitch(i), alt);
@@ -96,11 +65,7 @@ export class KeyboardSetup {
96
65
  }
97
66
 
98
67
  sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
99
- if (this.keyboard) {
100
- this.keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
101
- } else {
102
- console.warn("Tried to send keys before keyboard was initialised");
103
- }
68
+ this.keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
104
69
  }
105
70
 
106
71
  clearKeys() {
@@ -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) {
@@ -50,22 +50,20 @@ function readFileAsBinaryString(file) {
50
50
  * URL schema can name, the local file inputs, the drop zone and the built-in
51
51
  * list. Choosing what goes in a drive funnels through drives.putDiscIn.
52
52
  */
53
- export class MediaLoader {
53
+ export class MediaLoader extends EventTarget {
54
54
  /**
55
55
  * @param {object} deps
56
- * @param {object} deps.sources fetchers keyed by schema: sth, tapeSth and hfe
57
- * resolve an archive name to image data; drive loads a Google Drive file
58
56
  * @param {Function} deps.isSnapshotFile says whether a dropped file is a save state
59
57
  * @param {Function} deps.loadSnapshot restores a dropped save state
60
58
  */
61
- constructor({ processor, model, drives, urlState, config, modals, sources, isSnapshotFile, loadSnapshot }) {
59
+ constructor({ processor, model, drives, urlState, modals, isSnapshotFile, loadSnapshot }) {
60
+ super();
62
61
  this.processor = processor;
63
62
  this.model = model;
64
63
  this.drives = drives;
65
64
  this.urlState = urlState;
66
- this.config = config;
67
65
  this.modals = modals;
68
- this.sources = sources;
66
+ this.sources = {};
69
67
 
70
68
  document.getElementById("disc_load").addEventListener("change", async (evt) => {
71
69
  if (evt.target.files.length === 0) return;
@@ -105,8 +103,7 @@ export class MediaLoader {
105
103
  tapeName = unzipped.name;
106
104
  }
107
105
  this.setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
108
- delete this.params.tape;
109
- urlState.updateUrl();
106
+ urlState.set({ tape: undefined });
110
107
  modals.hide("tapes");
111
108
  } catch (error) {
112
109
  reportLoadFailure(file.name, error);
@@ -167,6 +164,11 @@ export class MediaLoader {
167
164
  return this.urlState.params;
168
165
  }
169
166
 
167
+ /** Register the fetcher behind an image schema; each picker calls this as it is constructed. */
168
+ addSource(schema, fetcher) {
169
+ this.sources[schema] = fetcher;
170
+ }
171
+
170
172
  /** Route tape to the correct interface (ACIA for BBC, PPIA for Atom) */
171
173
  setProcessorTape(tape) {
172
174
  if (this.model.isAtom) {
@@ -177,22 +179,18 @@ export class MediaLoader {
177
179
  }
178
180
 
179
181
  setDisc1Image(name) {
180
- delete this.params.disc;
181
- this.params.disc1 = name;
182
- this.urlState.updateUrl();
183
- this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
182
+ this.urlState.set({ disc: undefined, disc1: name });
183
+ this.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
184
184
  }
185
185
 
186
186
  setDisc2Image(name) {
187
- this.params.disc2 = name;
188
- this.urlState.updateUrl();
189
- this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
187
+ this.urlState.set({ disc2: name });
188
+ this.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
190
189
  }
191
190
 
192
191
  setTapeImage(name) {
193
- this.params.tape = name;
194
- this.urlState.updateUrl();
195
- this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
192
+ this.urlState.set({ tape: name });
193
+ this.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
196
194
  }
197
195
 
198
196
  async loadHTMLFile(file) {
@@ -207,15 +205,14 @@ export class MediaLoader {
207
205
  // Local file: retain the image bytes for embedding in save-to-file snapshots.
208
206
  loadedDisc.setOriginalImage(imageData);
209
207
  this.drives.putDiscIn(0, loadedDisc);
210
- delete this.params.disc;
211
- delete this.params.disc1;
212
- this.urlState.updateUrl();
208
+ this.urlState.set({ disc: undefined, disc1: undefined });
213
209
  this.modals.hide("discs");
214
210
  }
215
211
 
216
212
  async loadSCSIFile(file) {
217
- const binaryData = await readFileAsBinaryString(file);
218
213
  const { processor } = this;
214
+ if (!processor.filestore) return;
215
+ const binaryData = await readFileAsBinaryString(file);
219
216
  processor.filestore.scsi = utils.stringToUint8Array(binaryData);
220
217
 
221
218
  processor.filestore.PC = 0x400;
package/src/web/modals.js CHANGED
@@ -58,26 +58,29 @@ export class Modals {
58
58
  if (message) toast(message);
59
59
  }
60
60
 
61
- areYouSure(message, yesText, noText, yesFunc) {
61
+ /** @returns {Promise<boolean>} true for the yes button; false for any other way out of the dialog */
62
+ confirm(message, yesText, noText) {
62
63
  const yesButton = this.aysEl.querySelector(".ays-yes");
63
64
  this.aysEl.querySelector(".context").textContent = message;
64
65
  this.aysEl.querySelector(".ays-no").textContent = noText;
65
66
  yesButton.textContent = yesText;
66
- let confirmed = false;
67
- const onYes = () => {
68
- confirmed = true;
69
- this.aysModal.hide();
70
- };
71
- yesButton.addEventListener("click", onYes, { once: true });
72
- // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
73
- this.aysEl.addEventListener(
74
- "hidden.bs.modal",
75
- () => {
76
- yesButton.removeEventListener("click", onYes);
77
- if (confirmed) yesFunc();
78
- },
79
- { once: true },
80
- );
81
- this.aysModal.show();
67
+ return new Promise((resolve) => {
68
+ let confirmed = false;
69
+ const onYes = () => {
70
+ confirmed = true;
71
+ this.aysModal.hide();
72
+ };
73
+ yesButton.addEventListener("click", onYes, { once: true });
74
+ // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
75
+ this.aysEl.addEventListener(
76
+ "hidden.bs.modal",
77
+ () => {
78
+ yesButton.removeEventListener("click", onYes);
79
+ resolve(confirmed);
80
+ },
81
+ { once: true },
82
+ );
83
+ this.aysModal.show();
84
+ });
82
85
  }
83
86
  }
@@ -39,13 +39,13 @@ export class Settings {
39
39
  if (changed.displayMode) this.applyDisplayMode(changed.displayMode);
40
40
  },
41
41
  (changed) => this.onDialogClosed(changed),
42
- () => {
43
- this.targets.modals.areYouSure(
42
+ async () => {
43
+ const restart = await this.targets.modals.confirm(
44
44
  "Your change is saved, but only takes effect when the emulator restarts. Restart now?",
45
45
  "Restart now",
46
46
  "Later",
47
- () => window.location.reload(),
48
47
  );
48
+ if (restart) window.location.reload();
49
49
  },
50
50
  );
51
51
 
@@ -106,8 +106,7 @@ export class Settings {
106
106
  this.config.setAudioOutput(output);
107
107
  this.targets.quickSettings?.showAudioOutput(output);
108
108
  window.localStorage.audioOutput = output;
109
- this.urlState.params.audioOutput = output;
110
- this.urlState.updateUrl();
109
+ this.urlState.set({ audioOutput: output });
111
110
  }
112
111
 
113
112
  applySpeakerAmount(amount) {
@@ -123,18 +122,19 @@ export class Settings {
123
122
  applyDisplayMode(mode) {
124
123
  this.displayMode = mode;
125
124
  this.targets.display.setMode(mode);
125
+ // The monitor picture may have changed shape.
126
+ this.targets.layout.resize();
126
127
  this.config.setDisplayMode(mode);
127
128
  this.targets.quickSettings?.showDisplayMode(mode);
128
129
  window.localStorage.displayMode = mode;
129
- this.urlState.params.displayMode = mode;
130
- this.urlState.updateUrl();
130
+ this.urlState.set({ displayMode: mode });
131
131
  }
132
132
 
133
133
  onDialogClosed(changed) {
134
134
  const { urlState } = this;
135
135
  const { machine, keys, inputs } = this.targets;
136
136
  const parsedQuery = urlState.params;
137
- Object.assign(parsedQuery, changed);
137
+ urlState.set(changed);
138
138
  if (changed.keyLayout) {
139
139
  window.localStorage.keyLayout = changed.keyLayout;
140
140
  machine.emulationConfig.keyLayout = changed.keyLayout;
@@ -155,6 +155,5 @@ export class Settings {
155
155
  machine.processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
156
156
  }
157
157
  }
158
- urlState.updateUrl();
159
158
  }
160
159
  }
@@ -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);
@@ -46,6 +46,8 @@ export class SthPicker {
46
46
  onError,
47
47
  true,
48
48
  );
49
+ media.addSource("sth", (name) => this.discs.fetch(name));
50
+ media.addSource("tapeSth", (name) => this.tapes.fetch(name));
49
51
 
50
52
  document.addEventListener("click", (e) => {
51
53
  const target = e.target.closest("a.sth");
@@ -56,7 +56,8 @@ export const UrlParamTypes = {
56
56
 
57
57
  /**
58
58
  * The page's settings as carried in its URL. `params` is the one parsed
59
- * object: whoever changes a setting edits it in place and calls updateUrl.
59
+ * object: whoever changes a setting hands the change to set(), which edits
60
+ * it in place and writes the URL in one step.
60
61
  */
61
62
  export class UrlState {
62
63
  constructor(location, history, paramTypes = UrlParamTypes) {
@@ -78,6 +79,15 @@ export class UrlState {
78
79
  return buildUrlFromParams(this.baseUrl, { ...this.params, ...overrides }, this.paramTypes);
79
80
  }
80
81
 
82
+ /** Apply `changes` to the parameters (undefined deletes a key) and push the new URL. */
83
+ set(changes) {
84
+ for (const [key, value] of Object.entries(changes)) {
85
+ if (value === undefined) delete this.params[key];
86
+ else this.params[key] = value;
87
+ }
88
+ this.updateUrl();
89
+ }
90
+
81
91
  updateUrl() {
82
92
  this.history.pushState(null, null, this.url());
83
93
  }
File without changes