jsbeeb 1.22.1 → 1.22.3

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,4 +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%2Fbbc.xania.org%2Fcoverage%2Fbadge.json)](https://bbc.xania.org/coverage/index.html)
2
3
 
3
4
  # jsbeeb - JavaScript BBC Micro Emulator
4
5
 
@@ -15,6 +16,7 @@ different peripherals.
15
16
  - [Emulator Shortcuts](#emulator-shortcuts)
16
17
  - [Printer Output](#printer-output)
17
18
  - [Save State and Rewind](#save-state-and-rewind)
19
+ - [Joystick Support](#joystick-support)
18
20
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
19
21
  - [Running as a Desktop Application](#running-as-a-desktop-application)
20
22
  - [URL Parameters](#url-parameters)
@@ -119,10 +121,10 @@ Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S`
119
121
 
120
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:
121
123
 
122
- - **Left/Right arrows** navigate between snapshots (the main screen updates live)
124
+ - **Left/Right arrows**: navigate between snapshots (the main screen updates live)
123
125
  - **Click** a thumbnail to jump to that point
124
- - **Enter** commit selection and close the panel
125
- - **Escape** cancel and restore the original state
126
+ - **Enter**: commit selection and close the panel
127
+ - **Escape**: cancel and restore the original state
126
128
 
127
129
  ### Joystick Support
128
130
 
@@ -145,13 +147,13 @@ presses `SPACE`. To play Superior's Space Invaders on a pad, where `COPY` fires:
145
147
 
146
148
  The gamepad control names are:
147
149
 
148
- - `FIRE` every button at once, which is usually what you want for a one-button game
149
- - `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
150
152
  `X`, `LEFT` is `Y`, `RIGHT` is `B`)
151
- - `UP1` `DOWN1` `LEFT1` `RIGHT1` the left stick only; `UP2` `DOWN2` the right stick only; `UP3` `DOWN3` … — the
152
- face buttons only
153
- - `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT` individual buttons, by their Xbox 360 names
154
- - `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
155
157
 
156
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`
157
159
  both press `1`. Unlike `KEY.`, gamepad mappings are BBC-only: there's no Atom equivalent. The D-pad's default mapping
@@ -190,7 +192,9 @@ site to make it smaller and faster to load when it's deployed to [https://bbc.xa
190
192
 
191
193
  ## Running as a Desktop Application
192
194
 
193
- 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:
194
198
 
195
199
  ### Running in Development
196
200
 
@@ -234,17 +238,15 @@ USE_SYSTEM_FPM=true npm run electron:build
234
238
  **Debian/Ubuntu:**
235
239
 
236
240
  ```sh
237
- sudo apt install ./out/dist/jsbeeb_1.0.1_amd64.deb
241
+ sudo apt install ./out/dist/jsbeeb_<version>_amd64.deb
238
242
  ```
239
243
 
240
244
  **Fedora/RHEL/CentOS:**
241
245
 
242
246
  ```sh
243
- sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
247
+ sudo rpm -i out/dist/jsbeeb-<version>.x86_64.rpm
244
248
  ```
245
249
 
246
- **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.
247
-
248
250
  ## URL Parameters
249
251
 
250
252
  - `autoboot` - fakes a shift break
@@ -334,19 +336,11 @@ Note that every update you make means you need to make a new raw link.
334
336
 
335
337
  If you're looking to help:
336
338
 
337
- - Testing
338
- - Play lots of games and report issues either on [GitHub](https://github.com/mattgodbolt/jsbeeb/issues) or by email (
339
- matt@godbolt.org).
340
- - Core
341
- - Get the "boo" of the boot "boo-beep" working (disabled currently as the JavaScript startup makes the sound
342
- dreadfully choppy on Chrome at least).
343
- - Save disc support
344
- - Local discs need to be made more workable and need an "export" feature
345
- - Multiple discs need a UI
346
- - `git grep -i todo`
347
- - Optimisation
348
- - While every attempt to make things fast has been made, I'm sure there's some more clever things that can be done
349
- 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.
350
344
 
351
345
  ## Tests
352
346
 
@@ -369,12 +363,14 @@ For timing correctness, we have:
369
363
  correctness-sensitive when it comes to the timers and interrupts of the BBC.
370
364
  - Some 65C12-specific read-modify-write tests written by Ed Spittles.
371
365
 
372
- Tests can be run automatically if you have `node` installed - just run `make` and it'll ensure the relevant libraries
373
- 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.
374
370
 
375
371
  ## Thanks
376
372
 
377
- 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
378
374
  hard work and for open sourcing her code. B-em is now being maintained by a group of enthusiasts - thanks to them too!
379
375
 
380
376
  Huge thanks to Richard Talbot-Watkins for his advice and help along the way in fathoming out the instruction timings,
@@ -409,7 +405,8 @@ and posted the [video up on YouTube](https://www.youtube.com/watch?v=37jyHQT7fXQ
409
405
 
410
406
  ## License
411
407
 
412
- 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.
413
410
 
414
411
  ## Contact
415
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.1",
10
+ "version": "1.22.3",
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"
@@ -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
  }
@@ -1,5 +1,10 @@
1
1
  "use strict";
2
2
 
3
+ /** Where the OS sits waiting for a keypress, per machine: the place to interrupt with a program. */
4
+ export function basicIdleAddr(model) {
5
+ return model.isMaster ? 0xe7e6 : 0xe581;
6
+ }
7
+
3
8
  /**
4
9
  * Pokes a tokenised BASIC program into memory at PAGE, and sets TOP and
5
10
  * VARTOP after it, exactly as if it had just been typed.
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";
@@ -63,10 +64,7 @@ const noSeek = !!parsedQuery.noseek;
63
64
  const stationId = parsedQuery.stationId !== undefined ? parsedQuery.stationId : 101;
64
65
 
65
66
  const tryGl = parsedQuery.glEnabled ?? true;
66
- let lowLatency = true;
67
- if (parsedQuery.lowLatency !== undefined) {
68
- lowLatency = parsedQuery.lowLatency === "true";
69
- }
67
+ const lowLatency = parsedQuery.lowLatency ?? true;
70
68
 
71
69
  if (parsedQuery.embed) {
72
70
  for (const el of document.querySelectorAll(".embed-hide")) el.style.display = "none";
@@ -95,21 +93,7 @@ const printer = new Printer({
95
93
  }),
96
94
  });
97
95
 
98
- const keys = new KeyboardSetup({
99
- enterDebugger: () => loop.stop(true),
100
- reload: () => window.location.reload(),
101
- toggleFast: () => loop.toggleFastAsPossible(),
102
- openRewind: () => {
103
- if (rewindUI) rewindUI.open();
104
- },
105
- openPrinter: () => frontPanel.checkPrinterWindow(),
106
- pause: () => loop.stop(false),
107
- resume: () => loop.go(),
108
- onAnyKeyDown: () => {
109
- audioHandler.tryResume();
110
- inputs.ensureMicrophoneRunning();
111
- },
112
- });
96
+ const accessibilitySwitches = new AccessibilitySwitches();
113
97
 
114
98
  const settings = new Settings({ urlState });
115
99
  const { config, speechOutput, keyLayout, displayMode, audioOutput, speakerAmount } = settings;
@@ -138,12 +122,6 @@ if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
138
122
  const cpuSpeed = model.cyclesPerSecond;
139
123
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
140
124
 
141
- const modals = new Modals({
142
- isRunning: () => loop.isRunning(),
143
- stop: (debug) => loop.stop(debug),
144
- go: () => loop.go(),
145
- });
146
-
147
125
  const screenCanvas = document.getElementById("screen");
148
126
  const display = new Display({
149
127
  screenCanvas,
@@ -187,7 +165,7 @@ const quickSettings = new QuickSettings(
187
165
  );
188
166
 
189
167
  // ------------------------------------------------------------------------
190
- // The machine, and everything that feeds it.
168
+ // The machine, the loop that runs it, and everything that feeds it.
191
169
  // ------------------------------------------------------------------------
192
170
 
193
171
  // Depends on the settings having applied the URL parameters.
@@ -199,7 +177,7 @@ const machine = new Machine({
199
177
  cpuMultiplier,
200
178
  extraRoms,
201
179
  stationId,
202
- userPort: keys.userPort,
180
+ userPort: accessibilitySwitches.userPort,
203
181
  printer,
204
182
  speechOutput,
205
183
  video,
@@ -208,23 +186,63 @@ const machine = new Machine({
208
186
  });
209
187
  const processor = machine.processor;
210
188
 
211
- const drives = new Drives({ fdc: processor.fdc, driveTracks, areYouSure: modals.areYouSure.bind(modals) });
189
+ const keys = new KeyboardSetup({
190
+ actions: {
191
+ enterDebugger: () => loop.stop(true),
192
+ reload: () => window.location.reload(),
193
+ toggleFast: () => loop.toggleFastAsPossible(),
194
+ openRewind: () => rewindUI.open(),
195
+ openPrinter: () => frontPanel.checkPrinterWindow(),
196
+ pause: () => loop.stop(false),
197
+ resume: () => loop.go(),
198
+ onAnyKeyDown: () => {
199
+ audioHandler.tryResume();
200
+ inputs.ensureMicrophoneRunning();
201
+ },
202
+ },
203
+ accessibilitySwitches,
204
+ processor,
205
+ dbgr,
206
+ keyLayout,
207
+ });
208
+
209
+ const rewindBuffer = new RewindBuffer(30);
210
+ const loop = new EmulationLoop({
211
+ processor,
212
+ display,
213
+ audioHandler,
214
+ dbgr,
215
+ gamepad,
216
+ keyboard: keys,
217
+ syncLights: () => frontPanel.syncLights(),
218
+ rewindBuffer,
219
+ onRewindCaptured: () => rewindUI.updateButtonState(),
220
+ clocksPerSecond,
221
+ cpuSpeed,
222
+ fastTape: !!parsedQuery.fasttape,
223
+ audioStatsNode,
224
+ });
225
+
226
+ const debugPause = document.getElementById("debug-pause");
227
+ const debugPlay = document.getElementById("debug-play");
228
+ loop.addEventListener("running", () => {
229
+ const running = loop.isRunning();
230
+ keys.setRunning(running);
231
+ debugPlay.disabled = running;
232
+ debugPause.disabled = !running;
233
+ });
234
+
235
+ const modals = new Modals({ loop });
236
+
237
+ const drives = new Drives({ fdc: processor.fdc, driveTracks, confirm: modals.confirm.bind(modals) });
212
238
  const media = new MediaLoader({
213
239
  processor,
214
240
  model,
215
241
  drives,
216
242
  urlState,
217
- config,
218
243
  modals,
219
244
  isSnapshotFile,
220
245
  loadSnapshot: (file, buffer) => snapshots.loadStateFromFile(file, buffer),
221
- // The archives are created further down; each source resolves when used.
222
- sources: {
223
- sth: (name) => sthPicker.discs.fetch(name),
224
- tapeSth: (name) => sthPicker.tapes.fetch(name),
225
- hfe: (path) => hfePicker.archive.fetch(path),
226
- drive: (cat, layout) => drivePicker.load(cat, layout),
227
- },
228
246
  });
229
247
  const autoBoot = new Autoboot({
230
248
  model,
@@ -240,7 +258,7 @@ const sthPicker = new SthPicker({
240
258
  processor,
241
259
  autoboot: (image) => autoBoot.boot(image),
242
260
  });
243
- const hfePicker = new HfePicker({
261
+ new HfePicker({
244
262
  media,
245
263
  drives,
246
264
  modals,
@@ -248,7 +266,7 @@ const hfePicker = new HfePicker({
248
266
  processor,
249
267
  autoboot: (image) => autoBoot.boot(image),
250
268
  });
251
- const drivePicker = new GoogleDrivePicker({ media, drives, modals, processor });
269
+ new GoogleDrivePicker({ media, drives, modals, processor });
252
270
  const snapshots = new SnapshotUI({
253
271
  processor,
254
272
  model,
@@ -257,9 +275,7 @@ const snapshots = new SnapshotUI({
257
275
  drives,
258
276
  urlState,
259
277
  modals,
260
- isRunning: () => loop.isRunning(),
261
- stop: (debug) => loop.stop(debug),
262
- go: () => loop.go(),
278
+ loop,
263
279
  });
264
280
 
265
281
  const inputs = new AnalogueInputs({
@@ -280,55 +296,25 @@ if (parsedQuery.microphoneChannel !== undefined) {
280
296
  // Apply ADC source settings from URL parameters
281
297
  inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
282
298
 
283
- keys.attach({ processor, dbgr, keyLayout });
284
-
285
299
  const frontPanel = new FrontPanel({ processor, model, printer });
286
300
 
287
301
  // ------------------------------------------------------------------------
288
- // Running it: the loop, rewind and the visualiser.
302
+ // Rewind, the visualiser and the layout.
289
303
  // ------------------------------------------------------------------------
290
304
 
291
- const rewindBuffer = new RewindBuffer(30);
292
- const loop = new EmulationLoop({
293
- processor,
294
- display,
295
- audioHandler,
296
- dbgr,
297
- gamepad,
298
- keyboard: keys,
299
- syncLights: () => frontPanel.syncLights(),
300
- rewindBuffer,
301
- onRewindCaptured: () => rewindUI.updateButtonState(),
302
- clocksPerSecond,
303
- cpuSpeed,
304
- fastTape: !!parsedQuery.fasttape,
305
- audioStatsNode,
306
- });
307
-
308
- const debugPause = document.getElementById("debug-pause");
309
- const debugPlay = document.getElementById("debug-play");
310
- loop.addEventListener("running", () => {
311
- const running = loop.isRunning();
312
- keys.setRunning(running);
313
- debugPlay.disabled = running;
314
- debugPause.disabled = !running;
315
- });
316
-
317
305
  const rewindUI = new RewindUI({
318
306
  rewindBuffer,
319
307
  processor,
320
308
  video,
321
309
  captureInterval: RewindCaptureInterval,
322
- stop: (debug) => loop.stop(debug),
323
- go: () => loop.go(),
324
- isRunning: () => loop.isRunning(),
310
+ loop,
325
311
  });
326
312
  rewindUI.updateButtonState();
327
313
 
328
314
  if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
329
315
  else document.getElementById("disc-visualiser-open").classList.add("disabled");
330
316
 
331
- new Layout({
317
+ const layout = new Layout({
332
318
  screenCanvas,
333
319
  display,
334
320
  embed: parsedQuery.embed !== undefined,
@@ -336,7 +322,7 @@ new Layout({
336
322
  });
337
323
 
338
324
  // Everything a setting can reach now exists.
339
- settings.wire({ audioHandler, display, quickSettings, machine, keys, inputs, modals });
325
+ settings.wire({ audioHandler, display, layout, quickSettings, machine, keys, inputs, modals });
340
326
 
341
327
  // ------------------------------------------------------------------------
342
328
  // The page's own handlers.
@@ -391,11 +377,7 @@ window.addEventListener("beforeunload", function (event) {
391
377
  });
392
378
 
393
379
  function hardReset() {
394
- if (rewindUI) {
395
- rewindUI.close();
396
- rewindBuffer.clear();
397
- rewindUI.updateButtonState();
398
- }
380
+ rewindUI.reset();
399
381
  processor.reset(true);
400
382
  }
401
383
 
@@ -517,6 +499,7 @@ electron({
517
499
  loadTapeImage: media.loadTapeImage.bind(media),
518
500
  processor,
519
501
  config,
502
+ media,
520
503
  modals: {
521
504
  show: (modalId, sthType) => {
522
505
  if (modalId === "sth" && sthType) {
@@ -530,7 +513,7 @@ electron({
530
513
  actions: {
531
514
  "soft-reset": () => processor.reset(false),
532
515
  "hard-reset": hardReset,
533
- "save-state": () => document.getElementById("save-state").click(),
516
+ "save-state": () => snapshots.saveState(),
534
517
  rewind: () => rewindUI.open(),
535
518
  pause: pauseIntoDebugger,
536
519
  resume: resumeFromDebugger,
@@ -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
+ }
@@ -4,6 +4,8 @@ import { MouseJoystickSource } from "../mouse-joystick-source.js";
4
4
  import { calculateMouseCoordinates } from "../mouse-coordinates.js";
5
5
  import { toast } from "./toast.js";
6
6
 
7
+ const AdcChannelCount = 4;
8
+
7
9
  /**
8
10
  * What feeds the analogue port and the touchscreen: the gamepad, the mouse
9
11
  * acting as a joystick, and the microphone, with the mouse on the monitor
@@ -59,7 +61,7 @@ export class AnalogueInputs {
59
61
  updateAdcSources(mouseJoystickEnabled, microphoneChannel) {
60
62
  const { processor } = this;
61
63
  // Default all channels to the gamepad source.
62
- for (let ch = 0; ch < 4; ch++) {
64
+ for (let ch = 0; ch < AdcChannelCount; ch++) {
63
65
  processor.adconverter.setChannelSource(ch, this.gamepadSource);
64
66
  }
65
67
 
@@ -73,11 +75,24 @@ export class AnalogueInputs {
73
75
  }
74
76
 
75
77
  // Apply microphone if configured (can override any channel)
76
- if (microphoneChannel !== undefined) {
78
+ if (microphoneChannel === undefined) return;
79
+ if (Number.isInteger(microphoneChannel) && microphoneChannel >= 0 && microphoneChannel < AdcChannelCount) {
77
80
  processor.adconverter.setChannelSource(microphoneChannel, this.microphoneInput);
81
+ } else {
82
+ toast(
83
+ `There is no analogue channel ${microphoneChannel}; channels are 0 to 3. ` +
84
+ `The microphone channel has been turned off.`,
85
+ { title: "Microphone" },
86
+ );
87
+ this.clearMicrophoneChannel();
78
88
  }
79
89
  }
80
90
 
91
+ clearMicrophoneChannel() {
92
+ this.config.setMicrophoneChannel(undefined);
93
+ this.urlState.set({ microphoneChannel: undefined });
94
+ }
95
+
81
96
  async ensureMicrophoneRunning() {
82
97
  const { microphoneInput } = this;
83
98
  if (microphoneInput.audioContext && microphoneInput.audioContext.state !== "running") {
@@ -93,6 +108,8 @@ export class AnalogueInputs {
93
108
  }
94
109
 
95
110
  async setupMicrophone() {
111
+ // The channel can have been turned off between the request and now.
112
+ if (this.urlState.params.microphoneChannel === undefined) return;
96
113
  const micPermissionStatus = document.getElementById("micPermissionStatus");
97
114
  micPermissionStatus.textContent = "Requesting microphone access...";
98
115
 
@@ -110,10 +127,7 @@ export class AnalogueInputs {
110
127
  document.addEventListener("click", tryAgain);
111
128
  } else {
112
129
  micPermissionStatus.textContent = `Error: ${this.microphoneInput.getErrorMessage() || "Unknown error"}`;
113
- this.config.setMicrophoneChannel(undefined);
114
- // Update URL to remove the parameter
115
- delete this.urlState.params.microphoneChannel;
116
- this.urlState.updateUrl();
130
+ this.clearMicrophoneChannel();
117
131
  }
118
132
  }
119
133
  }
@@ -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
  }
@@ -14,8 +14,8 @@ const StallSpikeHeight = 20;
14
14
 
15
15
  // Nobody is watching an unfocused window, so its sound can run far behind
16
16
  // the picture, deep enough to ride out the browser starving the tab.
17
- export const UnfocusedLatencyMs = 200;
18
- export const DefaultLatencyMs = 20;
17
+ const UnfocusedLatencyMs = 200;
18
+ const DefaultLatencyMs = 20;
19
19
 
20
20
  export class AudioHandler {
21
21
  constructor({
@@ -1,7 +1,7 @@
1
1
  import * as utils from "../utils.js";
2
2
  import * as utils_atom from "../utils_atom.js";
3
3
  import * as tokeniser from "../basic-tokenise.js";
4
- import { installBasic } from "../basic-loader.js";
4
+ import { basicIdleAddr, installBasic } from "../basic-loader.js";
5
5
 
6
6
  /** Booting and typing for the machine at startup: shift-break, *TAPE incantations and BASIC programs. */
7
7
  export class Autoboot {
@@ -69,7 +69,7 @@ export class Autoboot {
69
69
  const tokenised = await t.tokenise(prog);
70
70
 
71
71
  const { processor } = this;
72
- const idleAddr = processor.model.isMaster ? 0xe7e6 : 0xe581;
72
+ const idleAddr = basicIdleAddr(processor.model);
73
73
  const hook = processor.debugInstruction.add((addr) => {
74
74
  if (addr !== idleAddr) return;
75
75
  installBasic(tokenised, {
package/src/web/config.js CHANGED
@@ -25,7 +25,7 @@ export function fittedRoms({ model, hasEconet, hasMusic5000, hasTeletextAdaptor
25
25
  }
26
26
 
27
27
  /** The settings the dialog presents as checkboxes. `enables` names a control only usable while ticked. */
28
- export const CheckboxSettings = [
28
+ const CheckboxSettings = [
29
29
  { id: "65c02", field: "coProcessor", restartRequired: true, enables: "tubeCpuMultiplier" },
30
30
  { id: "hasTeletextAdaptor", field: "hasTeletextAdaptor", restartRequired: true },
31
31
  { id: "hasEconet", field: "hasEconet", restartRequired: true },
package/src/web/debug.js CHANGED
@@ -544,7 +544,7 @@ export class Debugger {
544
544
  case "N":
545
545
  this.updatePrevMem();
546
546
  this.cpu.execute(1);
547
- self.debug(this.cpu.pc);
547
+ this.debug(this.cpu.pc);
548
548
  break;
549
549
  case "m":
550
550
  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
 
@@ -39,8 +39,6 @@ export class FrontPanel {
39
39
  } else {
40
40
  processor.acia.rewindTape();
41
41
  }
42
- } else {
43
- console.log("unknown type", type);
44
42
  }
45
43
  });
46
44
  }
@@ -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");
@@ -33,18 +34,18 @@ export class GoogleDrivePicker {
33
34
 
34
35
  // Loading the Google client holds the main thread for ~100ms, so it waits for
35
36
  // someone to ask for Drive.
36
- document.getElementById("open-drive-link").addEventListener("click", async () => {
37
+ document.getElementById("open-drive-link").addEventListener("click", async (e) => {
38
+ e.preventDefault();
37
39
  try {
38
40
  await this.googleDrive.initialise();
39
41
  } catch (error) {
40
42
  toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
41
- return false;
43
+ return;
42
44
  }
43
45
  const authed = await this.auth(false);
44
46
  if (authed) {
45
47
  this.modal.show();
46
48
  }
47
- return false;
48
49
  });
49
50
 
50
51
  this.el.addEventListener("show.bs.modal", () => this.showList());
@@ -55,9 +56,10 @@ export class GoogleDrivePicker {
55
56
  try {
56
57
  return await this.googleDrive.authorize(imm);
57
58
  } catch (err) {
58
- console.log("Error handling google auth: " + err);
59
+ console.log("Error handling google auth: " + errorText(err));
59
60
  this.el.querySelector(".loading").textContent =
60
- "There was an error accessing your Google Drive account: " + err;
61
+ `There was an error accessing your Google Drive account: ${errorText(err)}`;
62
+ return false;
61
63
  }
62
64
  }
63
65
 
@@ -131,7 +133,6 @@ export class GoogleDrivePicker {
131
133
  let name = document.querySelector("#google-drive .disc-name").value;
132
134
  if (!name) return;
133
135
 
134
- this.modals.popupLoading("Connecting to Google Drive");
135
136
  this.modal.hide();
136
137
  this.modals.popupLoading("Creating '" + name + "' on Google Drive");
137
138
 
@@ -150,7 +151,10 @@ export class GoogleDrivePicker {
150
151
  // TODO support HFE, I guess?
151
152
  const discType = disc.guessDiscTypeFromName(name);
152
153
  if (!discType.byteSize) {
153
- throw new Error(`Cannot create blank disc of type ${discType.extension} - unknown size`);
154
+ this.modals.loadingFinished(
155
+ `Unable to create ${name} on Google Drive: blank ${discType.extension} discs have no known size`,
156
+ );
157
+ return;
154
158
  }
155
159
  data = new Uint8Array(discType.byteSize);
156
160
  if (discType.supportsCatalogue) {
@@ -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() {
@@ -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,9 +205,7 @@ 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
 
@@ -309,6 +305,7 @@ export class MediaLoader {
309
305
  }
310
306
 
311
307
  async loadTapeImage(tapeImage) {
308
+ if (!tapeImage) return null;
312
309
  const split = splitImage(tapeImage);
313
310
  tapeImage = split.image;
314
311
  const schema = split.schema;
package/src/web/modals.js CHANGED
@@ -7,7 +7,7 @@ import { toast } from "./toast.js";
7
7
  * if it was running before.
8
8
  */
9
9
  export class Modals {
10
- constructor({ isRunning, stop, go }) {
10
+ constructor({ loop }) {
11
11
  this.errorDialog = document.getElementById("error-dialog");
12
12
  this.errorModal = new bootstrap.Modal(this.errorDialog);
13
13
  this.loadingDialog = document.getElementById("loading-dialog");
@@ -18,11 +18,11 @@ export class Modals {
18
18
 
19
19
  let savedRunning = false;
20
20
  document.addEventListener("show.bs.modal", () => {
21
- if (!this.anyVisible()) savedRunning = isRunning();
22
- if (isRunning()) stop(false);
21
+ if (!this.anyVisible()) savedRunning = loop.isRunning();
22
+ if (loop.isRunning()) loop.stop(false);
23
23
  });
24
24
  document.addEventListener("hidden.bs.modal", () => {
25
- if (!this.anyVisible() && savedRunning) go();
25
+ if (!this.anyVisible() && savedRunning) loop.go();
26
26
  });
27
27
  }
28
28
 
@@ -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
  }
@@ -13,18 +13,14 @@ export class RewindUI {
13
13
  * @param {object} options.processor - Cpu6502 instance
14
14
  * @param {object} options.video - Video instance
15
15
  * @param {number} options.captureInterval - rewind capture interval in frames
16
- * @param {function} options.stop - function to pause the emulator
17
- * @param {function} options.go - function to resume the emulator
18
- * @param {function} options.isRunning - function returning current running state
16
+ * @param {object} options.loop - the emulation loop, for pausing and resuming
19
17
  */
20
- constructor({ rewindBuffer, processor, video, captureInterval, stop, go, isRunning }) {
18
+ constructor({ rewindBuffer, processor, video, captureInterval, loop }) {
21
19
  this.rewindBuffer = rewindBuffer;
22
20
  this.processor = processor;
23
21
  this.video = video;
24
22
  this.captureInterval = captureInterval;
25
- this.stop = stop;
26
- this.go = go;
27
- this.isRunning = isRunning;
23
+ this.loop = loop;
28
24
 
29
25
  this.panel = document.getElementById("rewind-panel");
30
26
  this.filmstrip = document.getElementById("rewind-filmstrip");
@@ -45,6 +41,13 @@ export class RewindUI {
45
41
  });
46
42
  }
47
43
 
44
+ /** Forget everything captured, as on a hard reset. */
45
+ reset() {
46
+ this.close();
47
+ this.rewindBuffer.clear();
48
+ this.updateButtonState();
49
+ }
50
+
48
51
  /** Open the rewind scrubber panel. */
49
52
  open() {
50
53
  if (this.isOpen) return;
@@ -52,8 +55,8 @@ export class RewindUI {
52
55
  this.snapshots = this.rewindBuffer.getAll();
53
56
  if (this.snapshots.length === 0) return;
54
57
 
55
- this.wasRunning = this.isRunning();
56
- if (this.wasRunning) this.stop(false);
58
+ this.wasRunning = this.loop.isRunning();
59
+ if (this.wasRunning) this.loop.stop(false);
57
60
 
58
61
  this.isOpen = true;
59
62
  this.savedState = this.processor.snapshotState();
@@ -70,7 +73,7 @@ export class RewindUI {
70
73
  } catch (e) {
71
74
  this.processor.restoreState(this.savedState);
72
75
  this._closePanel();
73
- if (this.wasRunning) this.go();
76
+ if (this.wasRunning) this.loop.go();
74
77
  throw e;
75
78
  }
76
79
 
@@ -97,7 +100,7 @@ export class RewindUI {
97
100
  this.processor.restoreState(this.snapshots[this.selectedIndex]);
98
101
  }
99
102
  this._closePanel();
100
- if (this.wasRunning) this.go();
103
+ if (this.wasRunning) this.loop.go();
101
104
  }
102
105
 
103
106
  /**
@@ -107,7 +110,7 @@ export class RewindUI {
107
110
  if (!this.isOpen) return;
108
111
  this._renderState(this.savedState);
109
112
  this._closePanel();
110
- if (this.wasRunning) this.go();
113
+ if (this.wasRunning) this.loop.go();
111
114
  }
112
115
 
113
116
  /** Alias for cancel — closing the panel without explicit commit cancels. */
@@ -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,24 +122,25 @@ 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;
141
141
  keys.setKeyLayout(changed.keyLayout);
142
142
  }
143
- if (changed.mouseJoystickEnabled !== undefined || changed.microphoneChannel !== undefined) {
143
+ if (changed.mouseJoystickEnabled !== undefined || Object.hasOwn(changed, "microphoneChannel")) {
144
144
  inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
145
145
 
146
146
  if (changed.microphoneChannel !== undefined) {
@@ -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
  }
@@ -38,7 +38,7 @@ export function isSnapshotFile(filename, arrayBuffer) {
38
38
  }
39
39
 
40
40
  /** The saved snapshot in whichever of the formats we read the buffer holds. */
41
- export async function readSnapshot(arrayBuffer) {
41
+ async function readSnapshot(arrayBuffer) {
42
42
  if (isBemSnapshot(arrayBuffer)) return await parseBemSnapshot(arrayBuffer);
43
43
  if (isUefSnapshot(arrayBuffer)) return parseUefSnapshot(arrayBuffer);
44
44
  // Detect gzip (magic bytes 0x1f 0x8b) or plain JSON
@@ -84,7 +84,7 @@ export function snapshotMedia(fdcDrives, params) {
84
84
 
85
85
  /** Saving and restoring states: the menu item, the file input and the reload across a model change. */
86
86
  export class SnapshotUI {
87
- constructor({ processor, model, video, media, drives, urlState, modals, isRunning, stop, go }) {
87
+ constructor({ processor, model, video, media, drives, urlState, modals, loop }) {
88
88
  this.processor = processor;
89
89
  this.model = model;
90
90
  this.video = video;
@@ -92,9 +92,7 @@ export class SnapshotUI {
92
92
  this.drives = drives;
93
93
  this.urlState = urlState;
94
94
  this.modals = modals;
95
- this.isRunning = isRunning;
96
- this.stop = stop;
97
- this.go = go;
95
+ this.loop = loop;
98
96
 
99
97
  document.getElementById("save-state").addEventListener("click", async (event) => {
100
98
  event.preventDefault();
@@ -110,8 +108,8 @@ export class SnapshotUI {
110
108
  }
111
109
 
112
110
  async saveState() {
113
- const wasRunning = this.isRunning();
114
- if (wasRunning) this.stop(false);
111
+ const wasRunning = this.loop.isRunning();
112
+ if (wasRunning) this.loop.stop(false);
115
113
  try {
116
114
  const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params);
117
115
  const snapshot = createSnapshot(this.processor, this.model, manifest);
@@ -122,12 +120,12 @@ export class SnapshotUI {
122
120
  } catch (e) {
123
121
  this.modals.showError("saving state", e);
124
122
  }
125
- if (wasRunning) this.go();
123
+ if (wasRunning) this.loop.go();
126
124
  }
127
125
 
128
126
  async loadStateFromFile(file, preReadBuffer) {
129
- const wasRunning = this.isRunning();
130
- if (wasRunning) this.stop(false);
127
+ const wasRunning = this.loop.isRunning();
128
+ if (wasRunning) this.loop.stop(false);
131
129
  try {
132
130
  const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
133
131
  const snapshot = await readSnapshot(arrayBuffer);
@@ -146,7 +144,7 @@ export class SnapshotUI {
146
144
  } catch (e) {
147
145
  this.modals.showError("loading state", e);
148
146
  }
149
- if (wasRunning) this.go();
147
+ if (wasRunning) this.loop.go();
150
148
  }
151
149
 
152
150
  /** Picks up the state a cross-model reload stashed, once the matching machine is up. */
@@ -22,8 +22,18 @@ export class SthPicker {
22
22
  document.getElementById("sth-filter").focus();
23
23
  });
24
24
 
25
- const startLoad = () => showArchiveMessage("sth", "sth-list", "Loading catalog from STH archive");
26
- const onError = () => showArchiveMessage("sth", "sth-list", "There was an error accessing the STH archive");
25
+ // Anything that clears the list takes a new ticket; a chain whose ticket is
26
+ // stale gives up (the same scheme as hfe-picker.js).
27
+ this.renderTicket = 0;
28
+
29
+ const startLoad = () => {
30
+ this.renderTicket++;
31
+ showArchiveMessage("sth", "sth-list", "Loading catalog from STH archive");
32
+ };
33
+ const onError = () => {
34
+ this.renderTicket++;
35
+ showArchiveMessage("sth", "sth-list", "There was an error accessing the STH archive");
36
+ };
27
37
  this.discs = new StairwayToHell(
28
38
  startLoad,
29
39
  (cat) => this.renderCatalogue(cat, (item) => this.pickDisc(item)),
@@ -36,6 +46,8 @@ export class SthPicker {
36
46
  onError,
37
47
  true,
38
48
  );
49
+ media.addSource("sth", (name) => this.discs.fetch(name));
50
+ media.addSource("tapeSth", (name) => this.tapes.fetch(name));
39
51
 
40
52
  document.addEventListener("click", (e) => {
41
53
  const target = e.target.closest("a.sth");
@@ -95,17 +107,19 @@ export class SthPicker {
95
107
  }
96
108
 
97
109
  renderCatalogue(cat, onClick) {
110
+ const ticket = ++this.renderTicket;
98
111
  clearArchiveList("sth-list");
99
112
  const sthList = document.getElementById("sth-list");
100
113
  document.querySelector("#sth .loading").style.display = "none";
101
114
  const template = sthList.querySelector(".template");
102
115
 
103
116
  const doSome = (all) => {
117
+ if (ticket !== this.renderTicket) return;
104
118
  const MaxAtATime = 100;
105
119
  const Delay = 30;
106
120
  const batch = all.slice(0, MaxAtATime);
107
121
  const remaining = all.slice(MaxAtATime);
108
- const filter = document.getElementById("sth-filter").value;
122
+ const filter = document.getElementById("sth-filter").value.toLowerCase();
109
123
  for (const name of batch) {
110
124
  const row = template.cloneNode(true);
111
125
  row.classList.remove("template");
@@ -117,7 +131,7 @@ export class SthPicker {
117
131
  });
118
132
  row.style.display = name.toLowerCase().indexOf(filter) >= 0 ? "" : "none";
119
133
  }
120
- if (all.length) setTimeout(() => doSome(remaining), Delay);
134
+ if (remaining.length) setTimeout(() => doSome(remaining), Delay);
121
135
  };
122
136
 
123
137
  doSome(cat);
@@ -18,6 +18,7 @@ export const UrlParamTypes = {
18
18
  hasTeletextAdaptor: ParamTypes.BOOL,
19
19
  hasEconet: ParamTypes.BOOL,
20
20
  glEnabled: ParamTypes.BOOL,
21
+ lowLatency: ParamTypes.BOOL,
21
22
  fakeVideo: ParamTypes.BOOL,
22
23
  logFdcCommands: ParamTypes.BOOL,
23
24
  logFdcStateChanges: ParamTypes.BOOL,
@@ -55,7 +56,8 @@ export const UrlParamTypes = {
55
56
 
56
57
  /**
57
58
  * The page's settings as carried in its URL. `params` is the one parsed
58
- * 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.
59
61
  */
60
62
  export class UrlState {
61
63
  constructor(location, history, paramTypes = UrlParamTypes) {
@@ -77,6 +79,15 @@ export class UrlState {
77
79
  return buildUrlFromParams(this.baseUrl, { ...this.params, ...overrides }, this.paramTypes);
78
80
  }
79
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
+
80
91
  updateUrl() {
81
92
  this.history.pushState(null, null, this.url());
82
93
  }
@@ -1,4 +1,4 @@
1
- import { installBasic } from "../src/basic-loader.js";
1
+ import { basicIdleAddr, installBasic } from "../src/basic-loader.js";
2
2
  import * as fdc from "../src/fdc.js";
3
3
  import { fake6502 } from "../src/fake6502.js";
4
4
  import { findModel } from "../src/models.js";
@@ -182,7 +182,7 @@ export class TestMachine {
182
182
  assert(hit, "Atom did not reach keyboard input in time");
183
183
  return this.runFor(10 * 1000);
184
184
  }
185
- const idleAddr = this.processor.model.isMaster ? 0xe7e6 : 0xe581;
185
+ const idleAddr = basicIdleAddr(this.processor.model);
186
186
  let hit = false;
187
187
  const hook = this.processor.debugInstruction.add((addr) => {
188
188
  if (addr === idleAddr) {
File without changes