jsbeeb 1.22.4 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +2 -2
  2. package/package.json +16 -11
  3. package/src/6502.js +39 -42
  4. package/src/6502.opcodes.js +198 -123
  5. package/src/6847.js +4 -5
  6. package/src/6847_fontdata.js +0 -2
  7. package/src/acia.js +2 -3
  8. package/src/app/app.js +0 -1
  9. package/src/app/args.js +0 -2
  10. package/src/app/electron.js +4 -6
  11. package/src/app-bench.js +1 -3
  12. package/src/archive.js +243 -0
  13. package/src/basic-loader.js +0 -7
  14. package/src/basic-tokenise.js +19 -6
  15. package/src/bbc-palette.js +0 -2
  16. package/src/bbcdiscs.js +0 -2
  17. package/src/bem-snapshot.js +1 -3
  18. package/src/binary.js +175 -0
  19. package/src/cmos.js +0 -2
  20. package/src/ddnoise.js +0 -1
  21. package/src/debounce.js +7 -0
  22. package/src/disc-surface.js +0 -2
  23. package/src/disc.js +10 -9
  24. package/src/fake6502.js +7 -23
  25. package/src/fdc.js +9 -51
  26. package/src/filestore.js +10 -17
  27. package/src/hex.js +38 -0
  28. package/src/index.js +11 -0
  29. package/src/intel-fdc.js +18 -20
  30. package/src/jsbeeb.css +33 -0
  31. package/src/{utils_atom.js → keymap-atom.js} +2 -18
  32. package/src/{utils.js → keymap.js} +47 -610
  33. package/src/loader.js +63 -0
  34. package/src/machine-session.js +37 -177
  35. package/src/machine-spec.js +59 -0
  36. package/src/main.js +69 -184
  37. package/src/media-resolver.js +68 -0
  38. package/src/mmc.js +3 -2
  39. package/src/models.js +40 -4
  40. package/src/music5000.js +0 -2
  41. package/src/ppia.js +3 -4
  42. package/src/printer.js +7 -14
  43. package/src/relaynoise.js +0 -1
  44. package/src/rewind.js +0 -2
  45. package/src/sample-player.js +2 -3
  46. package/src/serial.js +0 -2
  47. package/src/snapshot-helpers.js +0 -2
  48. package/src/snapshot.js +0 -2
  49. package/src/soundchip.js +1 -1
  50. package/src/state-utils.js +0 -2
  51. package/src/sth.js +2 -4
  52. package/src/tapes.js +10 -4
  53. package/src/teletext.js +2 -3
  54. package/src/teletext_adaptor.js +2 -4
  55. package/src/teletext_data.js +0 -2
  56. package/src/test-machine.js +307 -0
  57. package/src/touchscreen.js +2 -4
  58. package/src/tube.js +5 -6
  59. package/src/typist.js +103 -0
  60. package/src/uef-snapshot.js +0 -2
  61. package/src/url-params.js +4 -5
  62. package/src/vdu-capture.js +113 -0
  63. package/src/via.js +3 -4
  64. package/src/video-filters/pal-composite.js +0 -2
  65. package/src/video-filters/passthrough-filter.js +0 -2
  66. package/src/video-filters/pixel-grid.js +0 -2
  67. package/src/video-filters/shader-program.js +0 -2
  68. package/src/video-filters/xbr-filter.js +0 -2
  69. package/src/video.js +12 -11
  70. package/src/wd-fdc.js +20 -20
  71. package/src/web/analogue-inputs.js +6 -8
  72. package/src/web/analytics.js +13 -0
  73. package/src/web/audio-handler.js +2 -2
  74. package/src/{audio-utils.js → web/audio-utils.js} +0 -2
  75. package/src/web/autoboot.js +10 -13
  76. package/src/{canvas.js → web/canvas.js} +4 -5
  77. package/src/web/config.js +42 -72
  78. package/src/web/console-surface.js +25 -0
  79. package/src/web/debug.js +8 -49
  80. package/src/web/disc-visualiser.js +0 -2
  81. package/src/web/display.js +21 -4
  82. package/src/{dom-utils.js → web/dom-utils.js} +1 -11
  83. package/src/web/drives.js +1 -1
  84. package/src/web/emulation-loop.js +54 -33
  85. package/src/web/front-panel.js +13 -12
  86. package/src/{gamepads.js → web/gamepads.js} +2 -6
  87. package/src/web/google-drive-picker.js +14 -9
  88. package/src/{google-drive.js → web/google-drive.js} +9 -10
  89. package/src/web/hfe-picker.js +5 -4
  90. package/src/web/icons.js +42 -0
  91. package/src/web/keyboard-setup.js +29 -40
  92. package/src/{keyboard.js → web/keyboard.js} +18 -119
  93. package/src/web/layout.js +4 -0
  94. package/src/web/local-disc.js +44 -0
  95. package/src/web/machine.js +27 -20
  96. package/src/web/media-loader.js +38 -147
  97. package/src/{microphone-input.js → web/microphone-input.js} +1 -1
  98. package/src/web/modals.js +6 -9
  99. package/src/web/page-actions.js +88 -0
  100. package/src/web/quick-settings.js +16 -15
  101. package/src/web/reporting.js +0 -7
  102. package/src/{rewind-thumbnail.js → web/rewind-thumbnail.js} +0 -2
  103. package/src/web/rewind-ui.js +29 -29
  104. package/src/web/run-controls.js +39 -0
  105. package/src/web/settings.js +64 -135
  106. package/src/web/snapshot-ui.js +10 -16
  107. package/src/{speech-output.js → web/speech-output.js} +0 -2
  108. package/src/web/sth-picker.js +9 -7
  109. package/src/web/url-state.js +20 -6
  110. package/src/econet/L3FS.dat +0 -0
  111. package/src/econet/scsi.dat +0 -0
  112. package/tests/test-machine.js +0 -609
@@ -1,6 +1,8 @@
1
- "use strict";
1
+ import { renderThumbnails, executeUntilFrame } from "./rewind-thumbnail.js";
2
+ import { RewindBuffer } from "../rewind.js";
3
+ import { RewindCaptureInterval } from "./emulation-loop.js";
2
4
 
3
- import { renderThumbnails, executeUntilFrame } from "../rewind-thumbnail.js";
5
+ const RewindBufferSnapshots = 30;
4
6
 
5
7
  /**
6
8
  * Rewind scrubber UI — a filmstrip overlay showing thumbnails of recent
@@ -9,18 +11,16 @@ import { renderThumbnails, executeUntilFrame } from "../rewind-thumbnail.js";
9
11
  export class RewindUI {
10
12
  /**
11
13
  * @param {object} options
12
- * @param {object} options.rewindBuffer - RewindBuffer instance
13
14
  * @param {object} options.processor - Cpu6502 instance
14
15
  * @param {object} options.video - Video instance
15
- * @param {number} options.captureInterval - rewind capture interval in frames
16
- * @param {object} options.loop - the emulation loop, for pausing and resuming
16
+ * @param {object} options.loop - the emulation loop: says when to capture, and is held while the panel is open
17
17
  */
18
- constructor({ rewindBuffer, processor, video, captureInterval, loop }) {
19
- this.rewindBuffer = rewindBuffer;
18
+ constructor({ processor, video, loop }) {
19
+ this.rewindBuffer = new RewindBuffer(RewindBufferSnapshots);
20
20
  this.processor = processor;
21
21
  this.video = video;
22
- this.captureInterval = captureInterval;
23
22
  this.loop = loop;
23
+ loop.addEventListener("rewind-capture", () => this.capture());
24
24
 
25
25
  this.panel = document.getElementById("rewind-panel");
26
26
  this.filmstrip = document.getElementById("rewind-filmstrip");
@@ -28,7 +28,7 @@ export class RewindUI {
28
28
  this.openBtn = document.getElementById("rewind-open");
29
29
 
30
30
  this.isOpen = false;
31
- this.wasRunning = false;
31
+ this.resumeLoop = null;
32
32
  this.selectedIndex = -1;
33
33
  this.snapshots = [];
34
34
  this.savedState = null;
@@ -41,6 +41,11 @@ export class RewindUI {
41
41
  });
42
42
  }
43
43
 
44
+ capture() {
45
+ this.rewindBuffer.push(this.processor.snapshotState());
46
+ this.updateButtonState();
47
+ }
48
+
44
49
  /** Forget everything captured, as on a hard reset. */
45
50
  reset() {
46
51
  this.close();
@@ -55,37 +60,32 @@ export class RewindUI {
55
60
  this.snapshots = this.rewindBuffer.getAll();
56
61
  if (this.snapshots.length === 0) return;
57
62
 
58
- this.wasRunning = this.loop.isRunning();
59
- if (this.wasRunning) this.loop.stop(false);
60
-
63
+ this.resumeLoop = this.loop.pause("the rewind panel");
61
64
  this.isOpen = true;
62
- this.savedState = this.processor.snapshotState();
63
-
64
65
  try {
66
+ this.savedState = this.processor.snapshotState();
65
67
  const thumbnails = renderThumbnails(
66
68
  this.processor,
67
69
  this.snapshots,
68
70
  this.video,
69
- this.captureInterval,
71
+ RewindCaptureInterval,
70
72
  this.savedState,
71
73
  );
72
74
  this._populateFilmstrip(thumbnails);
75
+ this.panel.hidden = false;
76
+ // Use capture phase so keys don't leak to the emulator's keyboard handler
77
+ document.addEventListener("keydown", this._onKeyDown, true);
78
+
79
+ // Select the newest snapshot ("now") and jump to it
80
+ this.selectedIndex = this.snapshots.length - 1;
81
+ this._restoreAndPaint(this.selectedIndex);
82
+ this._updateSelectionHighlight();
83
+ this.filmstrip.scrollLeft = this.filmstrip.scrollWidth;
73
84
  } catch (e) {
74
- this.processor.restoreState(this.savedState);
85
+ if (this.savedState) this.processor.restoreState(this.savedState);
75
86
  this._closePanel();
76
- if (this.wasRunning) this.loop.go();
77
87
  throw e;
78
88
  }
79
-
80
- this.panel.hidden = false;
81
- // Use capture phase so keys don't leak to the emulator's keyboard handler
82
- document.addEventListener("keydown", this._onKeyDown, true);
83
-
84
- // Select the newest snapshot ("now") and jump to it
85
- this.selectedIndex = this.snapshots.length - 1;
86
- this._restoreAndPaint(this.selectedIndex);
87
- this._updateSelectionHighlight();
88
- this.filmstrip.scrollLeft = this.filmstrip.scrollWidth;
89
89
  }
90
90
 
91
91
  /**
@@ -100,7 +100,6 @@ export class RewindUI {
100
100
  this.processor.restoreState(this.snapshots[this.selectedIndex]);
101
101
  }
102
102
  this._closePanel();
103
- if (this.wasRunning) this.loop.go();
104
103
  }
105
104
 
106
105
  /**
@@ -110,7 +109,6 @@ export class RewindUI {
110
109
  if (!this.isOpen) return;
111
110
  this._renderState(this.savedState);
112
111
  this._closePanel();
113
- if (this.wasRunning) this.loop.go();
114
112
  }
115
113
 
116
114
  /** Alias for cancel — closing the panel without explicit commit cancels. */
@@ -146,6 +144,8 @@ export class RewindUI {
146
144
  this.snapshots = [];
147
145
  this.savedState = null;
148
146
  document.removeEventListener("keydown", this._onKeyDown, true);
147
+ this.resumeLoop?.();
148
+ this.resumeLoop = null;
149
149
  }
150
150
 
151
151
  /**
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The play and pause buttons on the top bar, kept in step with the loop's
3
+ * running state. pause() and resume() are also the desktop app's menu actions.
4
+ */
5
+ export class RunControls {
6
+ /**
7
+ * @param {object} options
8
+ * @param {object} options.loop - the emulation loop
9
+ * @param {object} options.dbgr - the debugger, hidden on resume
10
+ * @param {object} options.keyboard - the machine keyboard, told the running state
11
+ */
12
+ constructor({ loop, dbgr, keyboard }) {
13
+ this.loop = loop;
14
+ this.dbgr = dbgr;
15
+ this.keyboard = keyboard;
16
+ this.playButton = document.getElementById("debug-play");
17
+ this.pauseButton = document.getElementById("debug-pause");
18
+
19
+ loop.addEventListener("running", () => {
20
+ const running = loop.isRunning();
21
+ keyboard.setRunning(running);
22
+ this.playButton.disabled = running;
23
+ this.pauseButton.disabled = !running;
24
+ });
25
+ this.pauseButton.addEventListener("click", () => this.pause());
26
+ this.playButton.addEventListener("click", () => this.resume());
27
+ }
28
+
29
+ /** Stop the loop into the debugger. */
30
+ pause() {
31
+ this.loop.stop(true);
32
+ }
33
+
34
+ /** Hide the debugger; the keyboard's resume event restarts the loop. */
35
+ resume() {
36
+ this.dbgr.hide();
37
+ this.keyboard.resumeEmulation();
38
+ }
39
+ }
@@ -1,159 +1,88 @@
1
- import * as utils from "../utils.js";
2
- import { Config } from "./config.js";
3
1
  import { DefaultModel, findModel } from "../models.js";
4
2
  import { DefaultAudioOutput, isAudioOutput } from "../audio-output.js";
5
- import { SpeechOutput } from "../speech-output.js";
6
3
  import { guessModelFromHostname } from "../url-params.js";
4
+ import { fittedRoms } from "./config.js";
7
5
  import { toast } from "./toast.js";
8
6
 
9
- // A slider fires for every pixel of a drag, and each URL update is a history entry.
10
- const UrlSettleMs = 300;
7
+ // Kept in browser storage for next time, as well as in the URL.
8
+ const StoredSettings = ["keyLayout", "displayMode", "audioOutput", "speakerAmount"];
9
+
10
+ /** The URL spellings of a model plus a fitting, from before fittings had settings of their own. */
11
+ export function mapLegacyModels(parsedQuery) {
12
+ if (!parsedQuery.model) return;
13
+ switch (parsedQuery.model.toLowerCase()) {
14
+ case "masterturbo":
15
+ parsedQuery.model = "Master";
16
+ parsedQuery.coProcessor = true;
17
+ break;
18
+ case "bmusic5000":
19
+ parsedQuery.model = "B-DFS1.2";
20
+ parsedQuery.hasMusic5000 = true;
21
+ break;
22
+ case "bteletext":
23
+ parsedQuery.model = "B-DFS1.2";
24
+ parsedQuery.hasTeletextAdaptor = true;
25
+ break;
26
+ }
27
+ }
11
28
 
12
29
  /**
13
- * The user's settings: resolved from the URL, browser storage and the
14
- * defaults, kept in step across the Settings dialog, the top bar, storage and
15
- * the URL, and applied to the machine. Built before anything it applies to;
16
- * wire() hands it the targets once they exist, which is before any setting
17
- * can change hands.
30
+ * The user's settings, resolved from the URL, browser storage and the
31
+ * defaults. set() adopts a change, persists it and dispatches an event named
32
+ * for each changed setting carrying its new value, then one "change" event
33
+ * carrying them all; whatever a setting reaches subscribes with on().
18
34
  */
19
- export class Settings {
20
- constructor({ urlState, makeConfig = (...handlers) => new Config(...handlers) }) {
35
+ export class Settings extends EventTarget {
36
+ constructor({ urlState }) {
37
+ super();
21
38
  this.urlState = urlState;
22
- this.targets = null;
23
- const parsedQuery = urlState.params;
24
-
25
- // Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
26
- // Must be created before Config so the onClose callback and the initial checkbox state can reference it.
27
- this.speechOutput = new SpeechOutput();
28
- this.setSpeechOutput(!!parsedQuery.speechOutput);
29
-
30
- this.keyLayout = window.localStorage.keyLayout || "physical";
31
- if (parsedQuery.keyLayout) {
32
- this.keyLayout = (parsedQuery.keyLayout + "").toLowerCase();
33
- }
34
-
35
- this.config = makeConfig(
36
- (changed) => {
37
- if (changed.audioOutput) this.applyAudioOutput(changed.audioOutput);
38
- if (changed.speakerAmount !== undefined) this.applySpeakerAmount(changed.speakerAmount);
39
- if (changed.displayMode) this.applyDisplayMode(changed.displayMode);
40
- },
41
- (changed) => this.onDialogClosed(changed),
42
- async () => {
43
- const restart = await this.targets.modals.confirm(
44
- "Your change is saved, but only takes effect when the emulator restarts. Restart now?",
45
- "Restart now",
46
- "Later",
47
- );
48
- if (restart) window.location.reload();
49
- },
50
- );
39
+ const params = urlState.params;
40
+ mapLegacyModels(params);
51
41
 
52
- // Perform mapping of legacy models to the new format
53
- this.config.mapLegacyModels(parsedQuery);
54
-
55
- const requestedModelName = parsedQuery.model || guessModelFromHostname(window.location.hostname);
56
- const requestedModel = findModel(requestedModelName);
57
- if (!requestedModel)
42
+ const requestedModelName = params.model || guessModelFromHostname(window.location.hostname);
43
+ this.model = findModel(requestedModelName);
44
+ if (!this.model) {
58
45
  toast(`There is no model called "${requestedModelName}". Using ${DefaultModel.name} instead.`, {
59
46
  title: "Model",
60
47
  });
61
- this.config.setModel((requestedModel ?? DefaultModel).name);
62
- this.config.setKeyLayout(this.keyLayout);
63
- this.config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
64
- this.config.setMicrophoneChannel(parsedQuery.microphoneChannel);
65
- this.config.setCheckboxes({
66
- coProcessor: !!parsedQuery.coProcessor,
67
- hasEconet: !!parsedQuery.hasEconet,
68
- hasMusic5000: !!parsedQuery.hasMusic5000,
69
- hasTeletextAdaptor: !!parsedQuery.hasTeletextAdaptor,
70
- mouseJoystickEnabled: !!parsedQuery.mouseJoystickEnabled,
71
- speechOutput: this.speechOutput.enabled,
72
- });
73
-
74
- this.displayMode = parsedQuery.displayMode || window.localStorage.displayMode || "rgb";
75
- this.config.setDisplayMode(this.displayMode);
48
+ this.model = DefaultModel;
49
+ }
50
+ this.keyLayout =
51
+ (params.keyLayout && `${params.keyLayout}`.toLowerCase()) || window.localStorage.keyLayout || "physical";
52
+ this.tubeCpuMultiplier = params.tubeCpuMultiplier || 1;
53
+ this.microphoneChannel = params.microphoneChannel;
54
+ this.coProcessor = !!params.coProcessor;
55
+ this.hasEconet = !!params.hasEconet;
56
+ this.hasMusic5000 = !!params.hasMusic5000;
57
+ this.hasTeletextAdaptor = !!params.hasTeletextAdaptor;
58
+ this.mouseJoystickEnabled = !!params.mouseJoystickEnabled;
59
+ this.speechOutput = !!params.speechOutput;
60
+ this.displayMode = params.displayMode || window.localStorage.displayMode || "rgb";
76
61
  this.audioOutput =
77
- [parsedQuery.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
62
+ [params.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
78
63
  this.speakerAmount =
79
- [parsedQuery.speakerAmount, parseFloat(window.localStorage.speakerAmount)].find(Number.isFinite) ?? 1;
80
- this.config.setAudioOutput(this.audioOutput);
81
- this.config.setSpeakerAmount(this.speakerAmount);
82
-
83
- this.updateUrlOnceSettled = utils.debounce(() => urlState.updateUrl(), UrlSettleMs);
84
- }
85
-
86
- get model() {
87
- return this.config.model;
88
- }
89
-
90
- /** What applying a setting reaches: everything here exists before a setting can change. */
91
- wire(targets) {
92
- this.targets = targets;
93
- }
94
-
95
- setSpeechOutput(enabled) {
96
- this.speechOutput.enabled = enabled;
97
- if (enabled && typeof speechSynthesis === "undefined")
98
- toast("This browser has no speech synthesis, so speech output has nothing to speak with.", {
99
- title: "Speech",
100
- });
64
+ [params.speakerAmount, parseFloat(window.localStorage.speakerAmount)].find(Number.isFinite) ?? 1;
101
65
  }
102
66
 
103
- applyAudioOutput(output) {
104
- this.audioOutput = output;
105
- this.targets.audioHandler.setAudioOutput(output);
106
- this.config.setAudioOutput(output);
107
- this.targets.quickSettings?.showAudioOutput(output);
108
- window.localStorage.audioOutput = output;
109
- this.urlState.set({ audioOutput: output });
67
+ get extraRoms() {
68
+ return fittedRoms(this);
110
69
  }
111
70
 
112
- applySpeakerAmount(amount) {
113
- this.speakerAmount = amount;
114
- this.targets.audioHandler.setSpeakerAmount(amount);
115
- this.config.setSpeakerAmount(amount);
116
- this.targets.quickSettings?.showSpeakerAmount(amount);
117
- window.localStorage.speakerAmount = amount;
118
- this.urlState.params.speakerAmount = amount;
119
- this.updateUrlOnceSettled();
71
+ /** Calls `listener` with the new value whenever the setting `name` is set. */
72
+ on(name, listener) {
73
+ this.addEventListener(name, () => listener(this[name]));
120
74
  }
121
75
 
122
- applyDisplayMode(mode) {
123
- this.displayMode = mode;
124
- this.targets.display.setMode(mode);
125
- // The monitor picture may have changed shape.
126
- this.targets.layout.resize();
127
- this.config.setDisplayMode(mode);
128
- this.targets.quickSettings?.showDisplayMode(mode);
129
- window.localStorage.displayMode = mode;
130
- this.urlState.set({ displayMode: mode });
131
- }
132
-
133
- onDialogClosed(changed) {
134
- const { urlState } = this;
135
- const { machine, keys, inputs } = this.targets;
136
- const parsedQuery = urlState.params;
137
- urlState.set(changed);
138
- if (changed.keyLayout) {
139
- window.localStorage.keyLayout = changed.keyLayout;
140
- machine.emulationConfig.keyLayout = changed.keyLayout;
141
- keys.setKeyLayout(changed.keyLayout);
142
- }
143
- if (changed.mouseJoystickEnabled !== undefined || Object.hasOwn(changed, "microphoneChannel")) {
144
- inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
145
-
146
- if (changed.microphoneChannel !== undefined) {
147
- inputs.setupMicrophone();
148
- }
149
- }
150
- if (changed.speechOutput !== undefined) this.setSpeechOutput(!!changed.speechOutput);
151
- if (changed.tubeCpuMultiplier !== undefined) {
152
- machine.emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
153
- this.config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
154
- if (machine.processor.hasTube) {
155
- machine.processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
156
- }
76
+ /** Adopts `changes` (an undefined value clears a setting), persists them and tells the subscribers. */
77
+ set(changes) {
78
+ for (const [name, value] of Object.entries(changes)) {
79
+ this[name] = name === "model" ? (findModel(value) ?? this.model) : value;
80
+ if (!StoredSettings.includes(name)) continue;
81
+ if (value === undefined) window.localStorage.removeItem(name);
82
+ else window.localStorage[name] = value;
157
83
  }
84
+ this.urlState.set(changes, { settle: "speakerAmount" in changes });
85
+ for (const name of Object.keys(changes)) this.dispatchEvent(new Event(name));
86
+ this.dispatchEvent(new CustomEvent("change", { detail: changes }));
158
87
  }
159
88
  }
@@ -1,6 +1,6 @@
1
1
  import * as disc from "../fdc.js";
2
2
  import { DiscLayout } from "../disc.js";
3
- import { downloadBlob } from "../dom-utils.js";
3
+ import { downloadBlob } from "./dom-utils.js";
4
4
  import {
5
5
  createSnapshot,
6
6
  restoreSnapshot,
@@ -113,9 +113,7 @@ export class SnapshotUI {
113
113
  }
114
114
 
115
115
  async saveState() {
116
- const wasRunning = this.loop.isRunning();
117
- if (wasRunning) this.loop.stop(false);
118
- let failure = null;
116
+ const resume = this.loop.pause("saving state");
119
117
  try {
120
118
  const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params, this.defaultBootDisc);
121
119
  const snapshot = createSnapshot(this.processor, this.model, manifest);
@@ -124,18 +122,14 @@ export class SnapshotUI {
124
122
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
125
123
  downloadBlob(blob, `jsbeeb-${this.model.name}-${timestamp}.json.gz`);
126
124
  } catch (e) {
127
- failure = e;
125
+ this.modals.showError("saving state", e);
126
+ } finally {
127
+ resume();
128
128
  }
129
- // Resume before reporting failure: the error dialog pauses a running
130
- // loop itself, and resumes it on close.
131
- if (wasRunning) this.loop.go();
132
- if (failure) this.modals.showError("saving state", failure);
133
129
  }
134
130
 
135
131
  async loadStateFromFile(file, preReadBuffer) {
136
- const wasRunning = this.loop.isRunning();
137
- if (wasRunning) this.loop.stop(false);
138
- let failure = null;
132
+ const resume = this.loop.pause("loading state");
139
133
  try {
140
134
  const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
141
135
  const snapshot = await readSnapshot(arrayBuffer);
@@ -152,10 +146,10 @@ export class SnapshotUI {
152
146
  // Force a repaint so the display updates even while paused
153
147
  this.video.paint();
154
148
  } catch (e) {
155
- failure = e;
149
+ this.modals.showError("loading state", e);
150
+ } finally {
151
+ resume();
156
152
  }
157
- if (wasRunning) this.loop.go();
158
- if (failure) this.modals.showError("loading state", failure);
159
153
  }
160
154
 
161
155
  /** Picks up the state a cross-model reload stashed, once the matching machine is up. */
@@ -199,7 +193,7 @@ export class SnapshotUI {
199
193
  ? savedMedia[imageDataKey]
200
194
  : new Uint8Array(Object.values(savedMedia[imageDataKey]));
201
195
  const discName = savedMedia[discKey + "Name"] || "snapshot.ssd";
202
- loadedDisc = disc.discFor(this.processor.fdc, discName, imageData, undefined, layout);
196
+ loadedDisc = disc.discFor(discName, imageData, undefined, layout);
203
197
  // Retain the image bytes so subsequent saves can re-embed them.
204
198
  loadedDisc.setOriginalImage(imageData);
205
199
  }
@@ -1,5 +1,3 @@
1
- "use strict";
2
-
3
1
  /**
4
2
  * RS-423 handler that routes transmitted bytes to the Web Speech API.
5
3
  *
@@ -1,8 +1,8 @@
1
- import * as utils from "../utils.js";
2
1
  import * as bootstrap from "bootstrap";
3
2
  import { StairwayToHell } from "../sth.js";
4
3
  import { errorText } from "./reporting.js";
5
4
  import { clearArchiveList, filterArchiveList, showArchiveMessage } from "./archive-list.js";
5
+ import { noteEvent } from "./analytics.js";
6
6
 
7
7
  /**
8
8
  * The Stairway to Hell archive picker: one modal browsing either the disc or
@@ -69,8 +69,9 @@ export class SthPicker {
69
69
  }
70
70
 
71
71
  async pickDisc(item) {
72
- utils.noteEvent("sth", "click", item);
73
- this.media.setDisc1Image("sth:" + item);
72
+ noteEvent("sth", "click", item);
73
+ const image = "sth:" + item;
74
+ this.media.setDisc1Image(image);
74
75
  const needsAutoboot = this.urlState.params.autoboot !== undefined;
75
76
  if (needsAutoboot) {
76
77
  this.processor.reset(true);
@@ -78,7 +79,7 @@ export class SthPicker {
78
79
 
79
80
  this.modals.popupLoading("Loading " + item);
80
81
  try {
81
- const loaded = await this.media.loadDiscImage(this.urlState.params.disc1, this.drives.layoutForDrive(0));
82
+ const loaded = await this.media.loadDiscImage(image, this.drives.layoutForDrive(0));
82
83
  this.drives.putDiscIn(0, loaded);
83
84
  this.modals.loadingFinished();
84
85
 
@@ -92,12 +93,13 @@ export class SthPicker {
92
93
  }
93
94
 
94
95
  async pickTape(item) {
95
- utils.noteEvent("sth", "clickTape", item);
96
- this.media.setTapeImage("sth:" + item);
96
+ noteEvent("sth", "clickTape", item);
97
+ const image = "sth:" + item;
98
+ this.media.setTapeImage(image);
97
99
 
98
100
  this.modals.popupLoading("Loading " + item);
99
101
  try {
100
- const tape = await this.media.loadTapeImage(this.urlState.params.tape);
102
+ const tape = await this.media.loadTapeImage(image);
101
103
  this.media.setProcessorTape(tape);
102
104
  this.modals.loadingFinished();
103
105
  } catch (err) {
@@ -1,4 +1,8 @@
1
1
  import { buildUrlFromParams, ParamTypes, parseQueryString } from "../url-params.js";
2
+ import { debounce } from "../debounce.js";
3
+
4
+ // A slider fires for every pixel of a drag, and each URL update is a history entry.
5
+ const UrlSettleMs = 300;
2
6
 
3
7
  /** How each parameter the page understands is parsed and written back. */
4
8
  export const UrlParamTypes = {
@@ -9,8 +13,6 @@ export const UrlParamTypes = {
9
13
  embed: ParamTypes.BOOL,
10
14
  fasttape: ParamTypes.BOOL,
11
15
  noseek: ParamTypes.BOOL,
12
- debug: ParamTypes.BOOL,
13
- verbose: ParamTypes.BOOL,
14
16
  autoboot: ParamTypes.BOOL,
15
17
  autochain: ParamTypes.BOOL,
16
18
  autorun: ParamTypes.BOOL,
@@ -28,9 +30,9 @@ export const UrlParamTypes = {
28
30
  audioDebug: ParamTypes.BOOL,
29
31
 
30
32
  // Numeric parameters
31
- speed: ParamTypes.INT,
32
33
  stationId: ParamTypes.INT,
33
34
  frameSkip: ParamTypes.INT,
35
+ videoCyclesBatch: ParamTypes.INT,
34
36
  audiofilterfreq: ParamTypes.FLOAT,
35
37
  audiofilterq: ParamTypes.FLOAT,
36
38
  speakerAmount: ParamTypes.FLOAT,
@@ -52,6 +54,12 @@ export const UrlParamTypes = {
52
54
  audioOutput: ParamTypes.STRING,
53
55
  drive0Tracks: ParamTypes.STRING,
54
56
  drive1Tracks: ParamTypes.STRING,
57
+ loadBasic: ParamTypes.STRING,
58
+ embedBasic: ParamTypes.STRING,
59
+ patch: ParamTypes.STRING,
60
+ sbLeft: ParamTypes.STRING,
61
+ sbRight: ParamTypes.STRING,
62
+ sbBottom: ParamTypes.STRING,
55
63
  };
56
64
 
57
65
  /**
@@ -67,6 +75,7 @@ export class UrlState {
67
75
  // Parameters may be given after the hash as well as in the query.
68
76
  const queryString = location.search.substring(1) + "&" + location.hash.substring(1);
69
77
  this.params = parseQueryString(queryString, paramTypes);
78
+ this.updateUrlOnceSettled = debounce(() => this.updateUrl(), UrlSettleMs);
70
79
  }
71
80
 
72
81
  /** The page's URL with the parameters as they are now. */
@@ -79,13 +88,18 @@ export class UrlState {
79
88
  return buildUrlFromParams(this.baseUrl, { ...this.params, ...overrides }, this.paramTypes);
80
89
  }
81
90
 
82
- /** Apply `changes` to the parameters (undefined deletes a key) and push the new URL. */
83
- set(changes) {
91
+ /**
92
+ * Apply `changes` to the parameters (undefined deletes a key) and push the
93
+ * new URL; `settle: true` lets a burst of changes finish before pushing one
94
+ * history entry for the lot.
95
+ */
96
+ set(changes, { settle = false } = {}) {
84
97
  for (const [key, value] of Object.entries(changes)) {
85
98
  if (value === undefined) delete this.params[key];
86
99
  else this.params[key] = value;
87
100
  }
88
- this.updateUrl();
101
+ if (settle) this.updateUrlOnceSettled();
102
+ else this.updateUrl();
89
103
  }
90
104
 
91
105
  updateUrl() {
Binary file
Binary file