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 +1 -0
- package/package.json +1 -1
- package/src/basic-loader.js +5 -0
- package/src/main.js +36 -53
- package/src/web/analogue-inputs.js +21 -6
- package/src/web/audio-handler.js +2 -2
- package/src/web/autoboot.js +2 -2
- package/src/web/config.js +1 -1
- package/src/web/front-panel.js +0 -2
- package/src/web/google-drive-picker.js +10 -7
- package/src/web/media-loader.js +1 -0
- package/src/web/modals.js +4 -4
- package/src/web/rewind-ui.js +15 -12
- package/src/web/settings.js +1 -1
- package/src/web/snapshot-ui.js +9 -11
- package/src/web/sth-picker.js +16 -4
- package/src/web/url-state.js +1 -0
- package/tests/test-machine.js +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
[](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml)
|
|
2
|
+
[](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.
|
|
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"
|
package/src/basic-loader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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": () =>
|
|
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 <
|
|
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
|
|
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.
|
|
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
|
}
|
package/src/web/audio-handler.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
17
|
+
const UnfocusedLatencyMs = 200;
|
|
18
|
+
const DefaultLatencyMs = 20;
|
|
19
19
|
|
|
20
20
|
export class AudioHandler {
|
|
21
21
|
constructor({
|
package/src/web/autoboot.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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/front-panel.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/web/media-loader.js
CHANGED
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({
|
|
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
|
|
package/src/web/rewind-ui.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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.
|
|
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. */
|
package/src/web/settings.js
CHANGED
|
@@ -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
|
|
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) {
|
package/src/web/snapshot-ui.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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.
|
|
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. */
|
package/src/web/sth-picker.js
CHANGED
|
@@ -22,8 +22,18 @@ export class SthPicker {
|
|
|
22
22
|
document.getElementById("sth-filter").focus();
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
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 (
|
|
132
|
+
if (remaining.length) setTimeout(() => doSome(remaining), Delay);
|
|
121
133
|
};
|
|
122
134
|
|
|
123
135
|
doSome(cat);
|
package/src/web/url-state.js
CHANGED
|
@@ -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,
|
package/tests/test-machine.js
CHANGED
|
@@ -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
|
|
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) {
|