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.
- package/README.md +2 -2
- package/package.json +16 -11
- package/src/6502.js +39 -42
- package/src/6502.opcodes.js +198 -123
- package/src/6847.js +4 -5
- package/src/6847_fontdata.js +0 -2
- package/src/acia.js +2 -3
- package/src/app/app.js +0 -1
- package/src/app/args.js +0 -2
- package/src/app/electron.js +4 -6
- package/src/app-bench.js +1 -3
- package/src/archive.js +243 -0
- package/src/basic-loader.js +0 -7
- package/src/basic-tokenise.js +19 -6
- package/src/bbc-palette.js +0 -2
- package/src/bbcdiscs.js +0 -2
- package/src/bem-snapshot.js +1 -3
- package/src/binary.js +175 -0
- package/src/cmos.js +0 -2
- package/src/ddnoise.js +0 -1
- package/src/debounce.js +7 -0
- package/src/disc-surface.js +0 -2
- package/src/disc.js +10 -9
- package/src/fake6502.js +7 -23
- package/src/fdc.js +9 -51
- package/src/filestore.js +10 -17
- package/src/hex.js +38 -0
- package/src/index.js +11 -0
- package/src/intel-fdc.js +18 -20
- package/src/jsbeeb.css +33 -0
- package/src/{utils_atom.js → keymap-atom.js} +2 -18
- package/src/{utils.js → keymap.js} +47 -610
- package/src/loader.js +63 -0
- package/src/machine-session.js +37 -177
- package/src/machine-spec.js +59 -0
- package/src/main.js +69 -184
- package/src/media-resolver.js +68 -0
- package/src/mmc.js +3 -2
- package/src/models.js +40 -4
- package/src/music5000.js +0 -2
- package/src/ppia.js +3 -4
- package/src/printer.js +7 -14
- package/src/relaynoise.js +0 -1
- package/src/rewind.js +0 -2
- package/src/sample-player.js +2 -3
- package/src/serial.js +0 -2
- package/src/snapshot-helpers.js +0 -2
- package/src/snapshot.js +0 -2
- package/src/soundchip.js +1 -1
- package/src/state-utils.js +0 -2
- package/src/sth.js +2 -4
- package/src/tapes.js +10 -4
- package/src/teletext.js +2 -3
- package/src/teletext_adaptor.js +2 -4
- package/src/teletext_data.js +0 -2
- package/src/test-machine.js +307 -0
- package/src/touchscreen.js +2 -4
- package/src/tube.js +5 -6
- package/src/typist.js +103 -0
- package/src/uef-snapshot.js +0 -2
- package/src/url-params.js +4 -5
- package/src/vdu-capture.js +113 -0
- package/src/via.js +3 -4
- package/src/video-filters/pal-composite.js +0 -2
- package/src/video-filters/passthrough-filter.js +0 -2
- package/src/video-filters/pixel-grid.js +0 -2
- package/src/video-filters/shader-program.js +0 -2
- package/src/video-filters/xbr-filter.js +0 -2
- package/src/video.js +12 -11
- package/src/wd-fdc.js +20 -20
- package/src/web/analogue-inputs.js +6 -8
- package/src/web/analytics.js +13 -0
- package/src/web/audio-handler.js +2 -2
- package/src/{audio-utils.js → web/audio-utils.js} +0 -2
- package/src/web/autoboot.js +10 -13
- package/src/{canvas.js → web/canvas.js} +4 -5
- package/src/web/config.js +42 -72
- package/src/web/console-surface.js +25 -0
- package/src/web/debug.js +8 -49
- package/src/web/disc-visualiser.js +0 -2
- package/src/web/display.js +21 -4
- package/src/{dom-utils.js → web/dom-utils.js} +1 -11
- package/src/web/drives.js +1 -1
- package/src/web/emulation-loop.js +54 -33
- package/src/web/front-panel.js +13 -12
- package/src/{gamepads.js → web/gamepads.js} +2 -6
- package/src/web/google-drive-picker.js +14 -9
- package/src/{google-drive.js → web/google-drive.js} +9 -10
- package/src/web/hfe-picker.js +5 -4
- package/src/web/icons.js +42 -0
- package/src/web/keyboard-setup.js +29 -40
- package/src/{keyboard.js → web/keyboard.js} +18 -119
- package/src/web/layout.js +4 -0
- package/src/web/local-disc.js +44 -0
- package/src/web/machine.js +27 -20
- package/src/web/media-loader.js +38 -147
- package/src/{microphone-input.js → web/microphone-input.js} +1 -1
- package/src/web/modals.js +6 -9
- package/src/web/page-actions.js +88 -0
- package/src/web/quick-settings.js +16 -15
- package/src/web/reporting.js +0 -7
- package/src/{rewind-thumbnail.js → web/rewind-thumbnail.js} +0 -2
- package/src/web/rewind-ui.js +29 -29
- package/src/web/run-controls.js +39 -0
- package/src/web/settings.js +64 -135
- package/src/web/snapshot-ui.js +10 -16
- package/src/{speech-output.js → web/speech-output.js} +0 -2
- package/src/web/sth-picker.js +9 -7
- package/src/web/url-state.js +20 -6
- package/src/econet/L3FS.dat +0 -0
- package/src/econet/scsi.dat +0 -0
- package/tests/test-machine.js +0 -609
package/src/web/rewind-ui.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import { renderThumbnails, executeUntilFrame } from "./rewind-thumbnail.js";
|
|
2
|
+
import { RewindBuffer } from "../rewind.js";
|
|
3
|
+
import { RewindCaptureInterval } from "./emulation-loop.js";
|
|
2
4
|
|
|
3
|
-
|
|
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 {
|
|
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({
|
|
19
|
-
this.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.
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/web/settings.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
const
|
|
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
|
|
14
|
-
* defaults
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
35
|
+
export class Settings extends EventTarget {
|
|
36
|
+
constructor({ urlState }) {
|
|
37
|
+
super();
|
|
21
38
|
this.urlState = urlState;
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
53
|
-
this.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
[
|
|
62
|
+
[params.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
|
|
78
63
|
this.speakerAmount =
|
|
79
|
-
[
|
|
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
|
-
|
|
104
|
-
this
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
this.
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
}
|
package/src/web/snapshot-ui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as disc from "../fdc.js";
|
|
2
2
|
import { DiscLayout } from "../disc.js";
|
|
3
|
-
import { downloadBlob } from "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/src/web/sth-picker.js
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
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(
|
|
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
|
-
|
|
96
|
-
|
|
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(
|
|
102
|
+
const tape = await this.media.loadTapeImage(image);
|
|
101
103
|
this.media.setProcessorTape(tape);
|
|
102
104
|
this.modals.loadingFinished();
|
|
103
105
|
} catch (err) {
|
package/src/web/url-state.js
CHANGED
|
@@ -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
|
-
/**
|
|
83
|
-
|
|
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.
|
|
101
|
+
if (settle) this.updateUrlOnceSettled();
|
|
102
|
+
else this.updateUrl();
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
updateUrl() {
|
package/src/econet/L3FS.dat
DELETED
|
Binary file
|
package/src/econet/scsi.dat
DELETED
|
Binary file
|