jsbeeb 1.22.1 → 1.22.2

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%2Fraw.githubusercontent.com%2Fmattgodbolt%2Fjsbeeb%2Fbadges%2Fcoverage.json)](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml)
2
3
 
3
4
  # jsbeeb - JavaScript BBC Micro Emulator
4
5
 
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.2",
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"
@@ -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
@@ -63,10 +63,7 @@ const noSeek = !!parsedQuery.noseek;
63
63
  const stationId = parsedQuery.stationId !== undefined ? parsedQuery.stationId : 101;
64
64
 
65
65
  const tryGl = parsedQuery.glEnabled ?? true;
66
- let lowLatency = true;
67
- if (parsedQuery.lowLatency !== undefined) {
68
- lowLatency = parsedQuery.lowLatency === "true";
69
- }
66
+ const lowLatency = parsedQuery.lowLatency ?? true;
70
67
 
71
68
  if (parsedQuery.embed) {
72
69
  for (const el of document.querySelectorAll(".embed-hide")) el.style.display = "none";
@@ -99,9 +96,7 @@ const keys = new KeyboardSetup({
99
96
  enterDebugger: () => loop.stop(true),
100
97
  reload: () => window.location.reload(),
101
98
  toggleFast: () => loop.toggleFastAsPossible(),
102
- openRewind: () => {
103
- if (rewindUI) rewindUI.open();
104
- },
99
+ openRewind: () => rewindUI.open(),
105
100
  openPrinter: () => frontPanel.checkPrinterWindow(),
106
101
  pause: () => loop.stop(false),
107
102
  resume: () => loop.go(),
@@ -138,12 +133,6 @@ if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
138
133
  const cpuSpeed = model.cyclesPerSecond;
139
134
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
140
135
 
141
- const modals = new Modals({
142
- isRunning: () => loop.isRunning(),
143
- stop: (debug) => loop.stop(debug),
144
- go: () => loop.go(),
145
- });
146
-
147
136
  const screenCanvas = document.getElementById("screen");
148
137
  const display = new Display({
149
138
  screenCanvas,
@@ -187,7 +176,7 @@ const quickSettings = new QuickSettings(
187
176
  );
188
177
 
189
178
  // ------------------------------------------------------------------------
190
- // The machine, and everything that feeds it.
179
+ // The machine, the loop that runs it, and everything that feeds it.
191
180
  // ------------------------------------------------------------------------
192
181
 
193
182
  // Depends on the settings having applied the URL parameters.
@@ -208,6 +197,34 @@ const machine = new Machine({
208
197
  });
209
198
  const processor = machine.processor;
210
199
 
200
+ const rewindBuffer = new RewindBuffer(30);
201
+ const loop = new EmulationLoop({
202
+ processor,
203
+ display,
204
+ audioHandler,
205
+ dbgr,
206
+ gamepad,
207
+ keyboard: keys,
208
+ syncLights: () => frontPanel.syncLights(),
209
+ rewindBuffer,
210
+ onRewindCaptured: () => rewindUI.updateButtonState(),
211
+ clocksPerSecond,
212
+ cpuSpeed,
213
+ fastTape: !!parsedQuery.fasttape,
214
+ audioStatsNode,
215
+ });
216
+
217
+ const debugPause = document.getElementById("debug-pause");
218
+ const debugPlay = document.getElementById("debug-play");
219
+ loop.addEventListener("running", () => {
220
+ const running = loop.isRunning();
221
+ keys.setRunning(running);
222
+ debugPlay.disabled = running;
223
+ debugPause.disabled = !running;
224
+ });
225
+
226
+ const modals = new Modals({ loop });
227
+
211
228
  const drives = new Drives({ fdc: processor.fdc, driveTracks, areYouSure: modals.areYouSure.bind(modals) });
212
229
  const media = new MediaLoader({
213
230
  processor,
@@ -257,9 +274,7 @@ const snapshots = new SnapshotUI({
257
274
  drives,
258
275
  urlState,
259
276
  modals,
260
- isRunning: () => loop.isRunning(),
261
- stop: (debug) => loop.stop(debug),
262
- go: () => loop.go(),
277
+ loop,
263
278
  });
264
279
 
265
280
  const inputs = new AnalogueInputs({
@@ -285,43 +300,15 @@ keys.attach({ processor, dbgr, keyLayout });
285
300
  const frontPanel = new FrontPanel({ processor, model, printer });
286
301
 
287
302
  // ------------------------------------------------------------------------
288
- // Running it: the loop, rewind and the visualiser.
303
+ // Rewind, the visualiser and the layout.
289
304
  // ------------------------------------------------------------------------
290
305
 
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
306
  const rewindUI = new RewindUI({
318
307
  rewindBuffer,
319
308
  processor,
320
309
  video,
321
310
  captureInterval: RewindCaptureInterval,
322
- stop: (debug) => loop.stop(debug),
323
- go: () => loop.go(),
324
- isRunning: () => loop.isRunning(),
311
+ loop,
325
312
  });
326
313
  rewindUI.updateButtonState();
327
314
 
@@ -391,11 +378,7 @@ window.addEventListener("beforeunload", function (event) {
391
378
  });
392
379
 
393
380
  function hardReset() {
394
- if (rewindUI) {
395
- rewindUI.close();
396
- rewindBuffer.clear();
397
- rewindUI.updateButtonState();
398
- }
381
+ rewindUI.reset();
399
382
  processor.reset(true);
400
383
  }
401
384
 
@@ -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,
@@ -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,25 @@ 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
+ delete this.urlState.params.microphoneChannel;
94
+ this.urlState.updateUrl();
95
+ }
96
+
81
97
  async ensureMicrophoneRunning() {
82
98
  const { microphoneInput } = this;
83
99
  if (microphoneInput.audioContext && microphoneInput.audioContext.state !== "running") {
@@ -93,6 +109,8 @@ export class AnalogueInputs {
93
109
  }
94
110
 
95
111
  async setupMicrophone() {
112
+ // The channel can have been turned off between the request and now.
113
+ if (this.urlState.params.microphoneChannel === undefined) return;
96
114
  const micPermissionStatus = document.getElementById("micPermissionStatus");
97
115
  micPermissionStatus.textContent = "Requesting microphone access...";
98
116
 
@@ -110,10 +128,7 @@ export class AnalogueInputs {
110
128
  document.addEventListener("click", tryAgain);
111
129
  } else {
112
130
  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();
131
+ this.clearMicrophoneChannel();
117
132
  }
118
133
  }
119
134
  }
@@ -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 },
@@ -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
  }
@@ -33,18 +33,18 @@ export class GoogleDrivePicker {
33
33
 
34
34
  // Loading the Google client holds the main thread for ~100ms, so it waits for
35
35
  // someone to ask for Drive.
36
- document.getElementById("open-drive-link").addEventListener("click", async () => {
36
+ document.getElementById("open-drive-link").addEventListener("click", async (e) => {
37
+ e.preventDefault();
37
38
  try {
38
39
  await this.googleDrive.initialise();
39
40
  } catch (error) {
40
41
  toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
41
- return false;
42
+ return;
42
43
  }
43
44
  const authed = await this.auth(false);
44
45
  if (authed) {
45
46
  this.modal.show();
46
47
  }
47
- return false;
48
48
  });
49
49
 
50
50
  this.el.addEventListener("show.bs.modal", () => this.showList());
@@ -55,9 +55,10 @@ export class GoogleDrivePicker {
55
55
  try {
56
56
  return await this.googleDrive.authorize(imm);
57
57
  } catch (err) {
58
- console.log("Error handling google auth: " + err);
58
+ console.log("Error handling google auth: " + errorText(err));
59
59
  this.el.querySelector(".loading").textContent =
60
- "There was an error accessing your Google Drive account: " + err;
60
+ `There was an error accessing your Google Drive account: ${errorText(err)}`;
61
+ return false;
61
62
  }
62
63
  }
63
64
 
@@ -131,7 +132,6 @@ export class GoogleDrivePicker {
131
132
  let name = document.querySelector("#google-drive .disc-name").value;
132
133
  if (!name) return;
133
134
 
134
- this.modals.popupLoading("Connecting to Google Drive");
135
135
  this.modal.hide();
136
136
  this.modals.popupLoading("Creating '" + name + "' on Google Drive");
137
137
 
@@ -150,7 +150,10 @@ export class GoogleDrivePicker {
150
150
  // TODO support HFE, I guess?
151
151
  const discType = disc.guessDiscTypeFromName(name);
152
152
  if (!discType.byteSize) {
153
- throw new Error(`Cannot create blank disc of type ${discType.extension} - unknown size`);
153
+ this.modals.loadingFinished(
154
+ `Unable to create ${name} on Google Drive: blank ${discType.extension} discs have no known size`,
155
+ );
156
+ return;
154
157
  }
155
158
  data = new Uint8Array(discType.byteSize);
156
159
  if (discType.supportsCatalogue) {
@@ -309,6 +309,7 @@ export class MediaLoader {
309
309
  }
310
310
 
311
311
  async loadTapeImage(tapeImage) {
312
+ if (!tapeImage) return null;
312
313
  const split = splitImage(tapeImage);
313
314
  tapeImage = split.image;
314
315
  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
 
@@ -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. */
@@ -140,7 +140,7 @@ export class Settings {
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) {
@@ -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)),
@@ -95,17 +105,19 @@ export class SthPicker {
95
105
  }
96
106
 
97
107
  renderCatalogue(cat, onClick) {
108
+ const ticket = ++this.renderTicket;
98
109
  clearArchiveList("sth-list");
99
110
  const sthList = document.getElementById("sth-list");
100
111
  document.querySelector("#sth .loading").style.display = "none";
101
112
  const template = sthList.querySelector(".template");
102
113
 
103
114
  const doSome = (all) => {
115
+ if (ticket !== this.renderTicket) return;
104
116
  const MaxAtATime = 100;
105
117
  const Delay = 30;
106
118
  const batch = all.slice(0, MaxAtATime);
107
119
  const remaining = all.slice(MaxAtATime);
108
- const filter = document.getElementById("sth-filter").value;
120
+ const filter = document.getElementById("sth-filter").value.toLowerCase();
109
121
  for (const name of batch) {
110
122
  const row = template.cloneNode(true);
111
123
  row.classList.remove("template");
@@ -117,7 +129,7 @@ export class SthPicker {
117
129
  });
118
130
  row.style.display = name.toLowerCase().indexOf(filter) >= 0 ? "" : "none";
119
131
  }
120
- if (all.length) setTimeout(() => doSome(remaining), Delay);
132
+ if (remaining.length) setTimeout(() => doSome(remaining), Delay);
121
133
  };
122
134
 
123
135
  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,
@@ -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) {