jsbeeb 1.22.0 → 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 +3 -2
- package/src/basic-loader.js +25 -0
- package/src/dom-utils.js +8 -0
- package/src/main.js +291 -2532
- package/src/utils.js +8 -0
- package/src/web/analogue-inputs.js +134 -0
- package/src/web/archive-list.js +44 -0
- package/src/web/audio-handler.js +2 -2
- package/src/web/autoboot.js +85 -0
- package/src/{config.js → web/config.js} +4 -4
- package/src/{disc-visualiser.js → web/disc-visualiser.js} +2 -2
- package/src/web/display.js +166 -0
- package/src/web/drives.js +116 -0
- package/src/web/emulation-loop.js +314 -0
- package/src/web/front-panel.js +133 -0
- package/src/web/google-drive-picker.js +175 -0
- package/src/web/hfe-picker.js +156 -0
- package/src/web/keyboard-setup.js +125 -0
- package/src/web/layout.js +156 -0
- package/src/web/machine.js +204 -0
- package/src/web/media-loader.js +359 -0
- package/src/web/modals.js +83 -0
- package/src/web/reporting.js +28 -0
- package/src/{rewind-ui.js → web/rewind-ui.js} +16 -13
- package/src/web/settings.js +160 -0
- package/src/web/snapshot-ui.js +217 -0
- package/src/web/sth-picker.js +137 -0
- package/src/web/url-state.js +84 -0
- package/tests/test-machine.js +6 -14
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as utils from "../utils.js";
|
|
2
|
+
import { Config } from "./config.js";
|
|
3
|
+
import { DefaultModel, findModel } from "../models.js";
|
|
4
|
+
import { DefaultAudioOutput, isAudioOutput } from "../audio-output.js";
|
|
5
|
+
import { SpeechOutput } from "../speech-output.js";
|
|
6
|
+
import { guessModelFromHostname } from "../url-params.js";
|
|
7
|
+
import { toast } from "./toast.js";
|
|
8
|
+
|
|
9
|
+
// A slider fires for every pixel of a drag, and each URL update is a history entry.
|
|
10
|
+
const UrlSettleMs = 300;
|
|
11
|
+
|
|
12
|
+
/**
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
export class Settings {
|
|
20
|
+
constructor({ urlState, makeConfig = (...handlers) => new Config(...handlers) }) {
|
|
21
|
+
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
|
+
() => {
|
|
43
|
+
this.targets.modals.areYouSure(
|
|
44
|
+
"Your change is saved, but only takes effect when the emulator restarts. Restart now?",
|
|
45
|
+
"Restart now",
|
|
46
|
+
"Later",
|
|
47
|
+
() => window.location.reload(),
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
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)
|
|
58
|
+
toast(`There is no model called "${requestedModelName}". Using ${DefaultModel.name} instead.`, {
|
|
59
|
+
title: "Model",
|
|
60
|
+
});
|
|
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);
|
|
76
|
+
this.audioOutput =
|
|
77
|
+
[parsedQuery.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
|
|
78
|
+
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
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
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.params.audioOutput = output;
|
|
110
|
+
this.urlState.updateUrl();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
applySpeakerAmount(amount) {
|
|
114
|
+
this.speakerAmount = amount;
|
|
115
|
+
this.targets.audioHandler.setSpeakerAmount(amount);
|
|
116
|
+
this.config.setSpeakerAmount(amount);
|
|
117
|
+
this.targets.quickSettings?.showSpeakerAmount(amount);
|
|
118
|
+
window.localStorage.speakerAmount = amount;
|
|
119
|
+
this.urlState.params.speakerAmount = amount;
|
|
120
|
+
this.updateUrlOnceSettled();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
applyDisplayMode(mode) {
|
|
124
|
+
this.displayMode = mode;
|
|
125
|
+
this.targets.display.setMode(mode);
|
|
126
|
+
this.config.setDisplayMode(mode);
|
|
127
|
+
this.targets.quickSettings?.showDisplayMode(mode);
|
|
128
|
+
window.localStorage.displayMode = mode;
|
|
129
|
+
this.urlState.params.displayMode = mode;
|
|
130
|
+
this.urlState.updateUrl();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onDialogClosed(changed) {
|
|
134
|
+
const { urlState } = this;
|
|
135
|
+
const { machine, keys, inputs } = this.targets;
|
|
136
|
+
const parsedQuery = urlState.params;
|
|
137
|
+
Object.assign(parsedQuery, 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
|
+
}
|
|
157
|
+
}
|
|
158
|
+
urlState.updateUrl();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import * as disc from "../fdc.js";
|
|
2
|
+
import { DiscLayout } from "../disc.js";
|
|
3
|
+
import { downloadBlob } from "../dom-utils.js";
|
|
4
|
+
import { toast } from "./toast.js";
|
|
5
|
+
import {
|
|
6
|
+
createSnapshot,
|
|
7
|
+
restoreSnapshot,
|
|
8
|
+
snapshotToJSON,
|
|
9
|
+
snapshotFromJSON,
|
|
10
|
+
isSameModel,
|
|
11
|
+
hasCoProcessor,
|
|
12
|
+
} from "../snapshot.js";
|
|
13
|
+
import { isBemSnapshot, parseBemSnapshot } from "../bem-snapshot.js";
|
|
14
|
+
import { isUefSnapshot, parseUefSnapshot } from "../uef-snapshot.js";
|
|
15
|
+
|
|
16
|
+
const PendingStateKey = "jsbeeb-pending-state";
|
|
17
|
+
|
|
18
|
+
/** Enough for the restored OS to settle before the user sees the screen. */
|
|
19
|
+
const PostRestoreCycles = 40000;
|
|
20
|
+
|
|
21
|
+
async function compressBlob(blob) {
|
|
22
|
+
const stream = blob.stream().pipeThrough(new CompressionStream("gzip"));
|
|
23
|
+
return new Response(stream).blob();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function decompressBlob(blob) {
|
|
27
|
+
const stream = blob.stream().pipeThrough(new DecompressionStream("gzip"));
|
|
28
|
+
return new Response(stream).blob();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isSnapshotFile(filename, arrayBuffer) {
|
|
32
|
+
const lower = filename.toLowerCase();
|
|
33
|
+
if (lower.endsWith(".snp") || lower.endsWith(".json") || lower.endsWith(".json.gz") || lower.endsWith(".gz"))
|
|
34
|
+
return true;
|
|
35
|
+
// .uef can be either a BeebEm save state or a regular tape image - check content
|
|
36
|
+
if (lower.endsWith(".uef") && arrayBuffer) return isUefSnapshot(arrayBuffer);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The saved snapshot in whichever of the formats we read the buffer holds. */
|
|
41
|
+
async function readSnapshot(arrayBuffer) {
|
|
42
|
+
if (isBemSnapshot(arrayBuffer)) return await parseBemSnapshot(arrayBuffer);
|
|
43
|
+
if (isUefSnapshot(arrayBuffer)) return parseUefSnapshot(arrayBuffer);
|
|
44
|
+
// Detect gzip (magic bytes 0x1f 0x8b) or plain JSON
|
|
45
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
46
|
+
let text;
|
|
47
|
+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
|
48
|
+
const decompressed = await decompressBlob(new Blob([arrayBuffer]));
|
|
49
|
+
text = await decompressed.text();
|
|
50
|
+
} else {
|
|
51
|
+
text = new TextDecoder().decode(arrayBuffer);
|
|
52
|
+
}
|
|
53
|
+
return snapshotFromJSON(text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a snapshot needs to put the same discs back: their URL references
|
|
58
|
+
* where they have one, the image bytes where they do not, and CRCs for
|
|
59
|
+
* saying when a source has changed underneath a state.
|
|
60
|
+
*/
|
|
61
|
+
export function snapshotMedia(fdcDrives, params) {
|
|
62
|
+
const manifest = {};
|
|
63
|
+
if (params.disc1 || params.disc) manifest.disc1 = params.disc1 || params.disc;
|
|
64
|
+
if (params.disc2) manifest.disc2 = params.disc2;
|
|
65
|
+
|
|
66
|
+
// For each drive with a disc loaded, include CRC32 for verification
|
|
67
|
+
// and embed original image data if no URL source exists (local file).
|
|
68
|
+
for (let driveIndex = 0; driveIndex < 2; driveIndex++) {
|
|
69
|
+
const driveDisc = fdcDrives[driveIndex].disc;
|
|
70
|
+
if (!driveDisc || driveDisc.originalImageCrc32 == null) continue;
|
|
71
|
+
const discKey = driveIndex === 0 ? "disc1" : "disc2";
|
|
72
|
+
const crcKey = discKey + "Crc32";
|
|
73
|
+
manifest[crcKey] = driveDisc.originalImageCrc32;
|
|
74
|
+
// The snapshot's dirty tracks are indexed by physical track, so restoring has to lay
|
|
75
|
+
// the disc out the way this one was rather than work it out again.
|
|
76
|
+
manifest[discKey + "Layout"] = driveDisc.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
|
|
77
|
+
if (!manifest[discKey] && driveDisc.originalImageData) {
|
|
78
|
+
manifest[discKey + "ImageData"] = driveDisc.originalImageData;
|
|
79
|
+
manifest[discKey + "Name"] = driveDisc.name;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return Object.keys(manifest).length > 0 ? manifest : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Saving and restoring states: the menu item, the file input and the reload across a model change. */
|
|
86
|
+
export class SnapshotUI {
|
|
87
|
+
constructor({ processor, model, video, media, drives, urlState, modals, loop }) {
|
|
88
|
+
this.processor = processor;
|
|
89
|
+
this.model = model;
|
|
90
|
+
this.video = video;
|
|
91
|
+
this.media = media;
|
|
92
|
+
this.drives = drives;
|
|
93
|
+
this.urlState = urlState;
|
|
94
|
+
this.modals = modals;
|
|
95
|
+
this.loop = loop;
|
|
96
|
+
|
|
97
|
+
document.getElementById("save-state").addEventListener("click", async (event) => {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
await this.saveState();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
document.getElementById("load-state").addEventListener("change", async (event) => {
|
|
103
|
+
const file = event.target.files[0];
|
|
104
|
+
if (!file) return;
|
|
105
|
+
event.target.value = "";
|
|
106
|
+
await this.loadStateFromFile(file);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async saveState() {
|
|
111
|
+
const wasRunning = this.loop.isRunning();
|
|
112
|
+
if (wasRunning) this.loop.stop(false);
|
|
113
|
+
try {
|
|
114
|
+
const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params);
|
|
115
|
+
const snapshot = createSnapshot(this.processor, this.model, manifest);
|
|
116
|
+
const json = snapshotToJSON(snapshot);
|
|
117
|
+
const blob = await compressBlob(new Blob([json]));
|
|
118
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
119
|
+
downloadBlob(blob, `jsbeeb-${this.model.name}-${timestamp}.json.gz`);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
this.modals.showError("saving state", e);
|
|
122
|
+
}
|
|
123
|
+
if (wasRunning) this.loop.go();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async loadStateFromFile(file, preReadBuffer) {
|
|
127
|
+
const wasRunning = this.loop.isRunning();
|
|
128
|
+
if (wasRunning) this.loop.stop(false);
|
|
129
|
+
try {
|
|
130
|
+
const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
|
|
131
|
+
const snapshot = await readSnapshot(arrayBuffer);
|
|
132
|
+
if (!isSameModel(snapshot.model, this.model.name) || hasCoProcessor(snapshot) !== this.processor.hasTube) {
|
|
133
|
+
// Model or co-processor mismatch: stash state and reload with a matching machine
|
|
134
|
+
sessionStorage.setItem(PendingStateKey, snapshotToJSON(snapshot));
|
|
135
|
+
window.location.href = this.urlState.urlWith({
|
|
136
|
+
model: snapshot.model,
|
|
137
|
+
coProcessor: hasCoProcessor(snapshot),
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await this.restore(snapshot);
|
|
142
|
+
// Force a repaint so the display updates even while paused
|
|
143
|
+
this.video.paint();
|
|
144
|
+
} catch (e) {
|
|
145
|
+
this.modals.showError("loading state", e);
|
|
146
|
+
}
|
|
147
|
+
if (wasRunning) this.loop.go();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Picks up the state a cross-model reload stashed, once the matching machine is up. */
|
|
151
|
+
async restorePendingState() {
|
|
152
|
+
const pendingState = sessionStorage.getItem(PendingStateKey);
|
|
153
|
+
if (!pendingState) return;
|
|
154
|
+
sessionStorage.removeItem(PendingStateKey);
|
|
155
|
+
try {
|
|
156
|
+
await this.restore(snapshotFromJSON(pendingState));
|
|
157
|
+
this.processor.execute(PostRestoreCycles);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
this.modals.showError("restoring saved state", e);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async restore(snapshot) {
|
|
164
|
+
// Order matters: reload disc media first so the base disc is in the
|
|
165
|
+
// drive before restoreSnapshot applies dirty track overlays on top.
|
|
166
|
+
await this.reloadSnapshotMedia(snapshot.media);
|
|
167
|
+
restoreSnapshot(this.processor, this.model, snapshot);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async reloadSnapshotMedia(savedMedia) {
|
|
171
|
+
if (!savedMedia) return;
|
|
172
|
+
for (let driveIndex = 0; driveIndex < 2; driveIndex++) {
|
|
173
|
+
const discKey = driveIndex === 0 ? "disc1" : "disc2";
|
|
174
|
+
const imageDataKey = discKey + "ImageData";
|
|
175
|
+
const crcKey = discKey + "Crc32";
|
|
176
|
+
|
|
177
|
+
// A snapshot from before layout detection has no field, and was contiguous.
|
|
178
|
+
const layout = savedMedia[discKey + "Layout"] ?? DiscLayout.contiguous;
|
|
179
|
+
|
|
180
|
+
let loadedDisc = null;
|
|
181
|
+
if (savedMedia[discKey]) {
|
|
182
|
+
// URL-based disc — reload from source
|
|
183
|
+
loadedDisc = await this.media.loadDiscImage(savedMedia[discKey], layout);
|
|
184
|
+
} else if (savedMedia[imageDataKey]) {
|
|
185
|
+
// Locally-loaded disc — reconstruct from embedded image data
|
|
186
|
+
const imageData =
|
|
187
|
+
savedMedia[imageDataKey] instanceof Uint8Array
|
|
188
|
+
? savedMedia[imageDataKey]
|
|
189
|
+
: new Uint8Array(Object.values(savedMedia[imageDataKey]));
|
|
190
|
+
const discName = savedMedia[discKey + "Name"] || "snapshot.ssd";
|
|
191
|
+
loadedDisc = disc.discFor(this.processor.fdc, discName, imageData, undefined, layout);
|
|
192
|
+
// Retain the image bytes so subsequent saves can re-embed them.
|
|
193
|
+
loadedDisc.setOriginalImage(imageData);
|
|
194
|
+
}
|
|
195
|
+
if (!loadedDisc) continue;
|
|
196
|
+
|
|
197
|
+
// Verify CRC32 if present
|
|
198
|
+
if (savedMedia[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
|
|
199
|
+
if (loadedDisc.originalImageCrc32 !== savedMedia[crcKey]) {
|
|
200
|
+
toast(
|
|
201
|
+
`${loadedDisc.name} has changed since this state was saved. The state has been restored anyway and may not run correctly.`,
|
|
202
|
+
{ title: "Restoring state" },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.drives.putDiscIn(driveIndex, loadedDisc);
|
|
208
|
+
// Only update the URL/query for URL-sourced discs. For embedded
|
|
209
|
+
// (local-file) discs, setting parsedQuery would put a bogus source
|
|
210
|
+
// in the URL and break subsequent saves/reloads.
|
|
211
|
+
if (savedMedia[discKey]) {
|
|
212
|
+
if (driveIndex === 0) this.media.setDisc1Image(savedMedia[discKey]);
|
|
213
|
+
else this.media.setDisc2Image(savedMedia[discKey]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as utils from "../utils.js";
|
|
2
|
+
import * as bootstrap from "bootstrap";
|
|
3
|
+
import { StairwayToHell } from "../sth.js";
|
|
4
|
+
import { errorText } from "./reporting.js";
|
|
5
|
+
import { clearArchiveList, filterArchiveList, showArchiveMessage } from "./archive-list.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Stairway to Hell archive picker: one modal browsing either the disc or
|
|
9
|
+
* the tape catalogue, filtered as it renders.
|
|
10
|
+
*/
|
|
11
|
+
export class SthPicker {
|
|
12
|
+
constructor({ media, drives, modals, urlState, processor, autoboot }) {
|
|
13
|
+
this.media = media;
|
|
14
|
+
this.drives = drives;
|
|
15
|
+
this.modals = modals;
|
|
16
|
+
this.urlState = urlState;
|
|
17
|
+
this.processor = processor;
|
|
18
|
+
this.autoboot = autoboot;
|
|
19
|
+
|
|
20
|
+
this.modal = new bootstrap.Modal(document.getElementById("sth"));
|
|
21
|
+
document.getElementById("sth").addEventListener("shown.bs.modal", () => {
|
|
22
|
+
document.getElementById("sth-filter").focus();
|
|
23
|
+
});
|
|
24
|
+
|
|
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
|
+
};
|
|
37
|
+
this.discs = new StairwayToHell(
|
|
38
|
+
startLoad,
|
|
39
|
+
(cat) => this.renderCatalogue(cat, (item) => this.pickDisc(item)),
|
|
40
|
+
onError,
|
|
41
|
+
false,
|
|
42
|
+
);
|
|
43
|
+
this.tapes = new StairwayToHell(
|
|
44
|
+
startLoad,
|
|
45
|
+
(cat) => this.renderCatalogue(cat, (item) => this.pickTape(item)),
|
|
46
|
+
onError,
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
document.addEventListener("click", (e) => {
|
|
51
|
+
const target = e.target.closest("a.sth");
|
|
52
|
+
if (!target) return;
|
|
53
|
+
const type = target.dataset.id;
|
|
54
|
+
if (type === "discs") {
|
|
55
|
+
this.discs.populate();
|
|
56
|
+
} else if (type === "tapes") {
|
|
57
|
+
this.tapes.populate();
|
|
58
|
+
} else {
|
|
59
|
+
console.log("unknown id", type);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const sthFilter = document.getElementById("sth-filter");
|
|
64
|
+
const applyFilter = () => filterArchiveList("sth-list", sthFilter.value);
|
|
65
|
+
sthFilter.addEventListener("change", applyFilter);
|
|
66
|
+
sthFilter.addEventListener("keyup", applyFilter);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async pickDisc(item) {
|
|
70
|
+
utils.noteEvent("sth", "click", item);
|
|
71
|
+
this.media.setDisc1Image("sth:" + item);
|
|
72
|
+
const needsAutoboot = this.urlState.params.autoboot !== undefined;
|
|
73
|
+
if (needsAutoboot) {
|
|
74
|
+
this.processor.reset(true);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.modals.popupLoading("Loading " + item);
|
|
78
|
+
try {
|
|
79
|
+
const loaded = await this.media.loadDiscImage(this.urlState.params.disc1, this.drives.layoutForDrive(0));
|
|
80
|
+
this.drives.putDiscIn(0, loaded);
|
|
81
|
+
this.modals.loadingFinished();
|
|
82
|
+
|
|
83
|
+
if (needsAutoboot) {
|
|
84
|
+
this.autoboot(item);
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error("Error loading disc image:", err);
|
|
88
|
+
this.modals.loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async pickTape(item) {
|
|
93
|
+
utils.noteEvent("sth", "clickTape", item);
|
|
94
|
+
this.media.setTapeImage("sth:" + item);
|
|
95
|
+
|
|
96
|
+
this.modals.popupLoading("Loading " + item);
|
|
97
|
+
try {
|
|
98
|
+
const tape = await this.media.loadTapeImage(this.urlState.params.tape);
|
|
99
|
+
this.media.setProcessorTape(tape);
|
|
100
|
+
this.modals.loadingFinished();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error("Error loading tape image:", err);
|
|
103
|
+
this.modals.loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
renderCatalogue(cat, onClick) {
|
|
108
|
+
const ticket = ++this.renderTicket;
|
|
109
|
+
clearArchiveList("sth-list");
|
|
110
|
+
const sthList = document.getElementById("sth-list");
|
|
111
|
+
document.querySelector("#sth .loading").style.display = "none";
|
|
112
|
+
const template = sthList.querySelector(".template");
|
|
113
|
+
|
|
114
|
+
const doSome = (all) => {
|
|
115
|
+
if (ticket !== this.renderTicket) return;
|
|
116
|
+
const MaxAtATime = 100;
|
|
117
|
+
const Delay = 30;
|
|
118
|
+
const batch = all.slice(0, MaxAtATime);
|
|
119
|
+
const remaining = all.slice(MaxAtATime);
|
|
120
|
+
const filter = document.getElementById("sth-filter").value.toLowerCase();
|
|
121
|
+
for (const name of batch) {
|
|
122
|
+
const row = template.cloneNode(true);
|
|
123
|
+
row.classList.remove("template");
|
|
124
|
+
sthList.appendChild(row);
|
|
125
|
+
row.querySelector(".name").textContent = name;
|
|
126
|
+
row.addEventListener("click", () => {
|
|
127
|
+
onClick(name);
|
|
128
|
+
this.modal.hide();
|
|
129
|
+
});
|
|
130
|
+
row.style.display = name.toLowerCase().indexOf(filter) >= 0 ? "" : "none";
|
|
131
|
+
}
|
|
132
|
+
if (remaining.length) setTimeout(() => doSome(remaining), Delay);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
doSome(cat);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { buildUrlFromParams, ParamTypes, parseQueryString } from "../url-params.js";
|
|
2
|
+
|
|
3
|
+
/** How each parameter the page understands is parsed and written back. */
|
|
4
|
+
export const UrlParamTypes = {
|
|
5
|
+
// Array parameters
|
|
6
|
+
rom: ParamTypes.ARRAY,
|
|
7
|
+
|
|
8
|
+
// Boolean parameters
|
|
9
|
+
embed: ParamTypes.BOOL,
|
|
10
|
+
fasttape: ParamTypes.BOOL,
|
|
11
|
+
noseek: ParamTypes.BOOL,
|
|
12
|
+
debug: ParamTypes.BOOL,
|
|
13
|
+
verbose: ParamTypes.BOOL,
|
|
14
|
+
autoboot: ParamTypes.BOOL,
|
|
15
|
+
autochain: ParamTypes.BOOL,
|
|
16
|
+
autorun: ParamTypes.BOOL,
|
|
17
|
+
hasMusic5000: ParamTypes.BOOL,
|
|
18
|
+
hasTeletextAdaptor: ParamTypes.BOOL,
|
|
19
|
+
hasEconet: ParamTypes.BOOL,
|
|
20
|
+
glEnabled: ParamTypes.BOOL,
|
|
21
|
+
lowLatency: ParamTypes.BOOL,
|
|
22
|
+
fakeVideo: ParamTypes.BOOL,
|
|
23
|
+
logFdcCommands: ParamTypes.BOOL,
|
|
24
|
+
logFdcStateChanges: ParamTypes.BOOL,
|
|
25
|
+
coProcessor: ParamTypes.BOOL,
|
|
26
|
+
mouseJoystickEnabled: ParamTypes.BOOL,
|
|
27
|
+
speechOutput: ParamTypes.BOOL,
|
|
28
|
+
audioDebug: ParamTypes.BOOL,
|
|
29
|
+
|
|
30
|
+
// Numeric parameters
|
|
31
|
+
speed: ParamTypes.INT,
|
|
32
|
+
stationId: ParamTypes.INT,
|
|
33
|
+
frameSkip: ParamTypes.INT,
|
|
34
|
+
audiofilterfreq: ParamTypes.FLOAT,
|
|
35
|
+
audiofilterq: ParamTypes.FLOAT,
|
|
36
|
+
speakerAmount: ParamTypes.FLOAT,
|
|
37
|
+
audioLatencyMs: ParamTypes.FLOAT,
|
|
38
|
+
cpuMultiplier: ParamTypes.FLOAT,
|
|
39
|
+
tubeCpuMultiplier: ParamTypes.FLOAT,
|
|
40
|
+
microphoneChannel: ParamTypes.INT,
|
|
41
|
+
|
|
42
|
+
// String parameters (these are the default but listed for clarity)
|
|
43
|
+
model: ParamTypes.STRING,
|
|
44
|
+
disc: ParamTypes.STRING,
|
|
45
|
+
disc1: ParamTypes.STRING,
|
|
46
|
+
disc2: ParamTypes.STRING,
|
|
47
|
+
tape: ParamTypes.STRING,
|
|
48
|
+
mmc: ParamTypes.STRING,
|
|
49
|
+
keyLayout: ParamTypes.STRING,
|
|
50
|
+
autotype: ParamTypes.STRING,
|
|
51
|
+
displayMode: ParamTypes.STRING,
|
|
52
|
+
audioOutput: ParamTypes.STRING,
|
|
53
|
+
drive0Tracks: ParamTypes.STRING,
|
|
54
|
+
drive1Tracks: ParamTypes.STRING,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The page's settings as carried in its URL. `params` is the one parsed
|
|
59
|
+
* object: whoever changes a setting edits it in place and calls updateUrl.
|
|
60
|
+
*/
|
|
61
|
+
export class UrlState {
|
|
62
|
+
constructor(location, history, paramTypes = UrlParamTypes) {
|
|
63
|
+
this.history = history;
|
|
64
|
+
this.paramTypes = paramTypes;
|
|
65
|
+
this.baseUrl = location.origin + location.pathname;
|
|
66
|
+
// Parameters may be given after the hash as well as in the query.
|
|
67
|
+
const queryString = location.search.substring(1) + "&" + location.hash.substring(1);
|
|
68
|
+
this.params = parseQueryString(queryString, paramTypes);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The page's URL with the parameters as they are now. */
|
|
72
|
+
url() {
|
|
73
|
+
return buildUrlFromParams(this.baseUrl, this.params, this.paramTypes);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The page's URL with some parameters changed, leaving `params` as it is. */
|
|
77
|
+
urlWith(overrides) {
|
|
78
|
+
return buildUrlFromParams(this.baseUrl, { ...this.params, ...overrides }, this.paramTypes);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
updateUrl() {
|
|
82
|
+
this.history.pushState(null, null, this.url());
|
|
83
|
+
}
|
|
84
|
+
}
|
package/tests/test-machine.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { basicIdleAddr, installBasic } from "../src/basic-loader.js";
|
|
1
2
|
import * as fdc from "../src/fdc.js";
|
|
2
3
|
import { fake6502 } from "../src/fake6502.js";
|
|
3
4
|
import { findModel } from "../src/models.js";
|
|
@@ -181,7 +182,7 @@ export class TestMachine {
|
|
|
181
182
|
assert(hit, "Atom did not reach keyboard input in time");
|
|
182
183
|
return this.runFor(10 * 1000);
|
|
183
184
|
}
|
|
184
|
-
const idleAddr = this.processor.model
|
|
185
|
+
const idleAddr = basicIdleAddr(this.processor.model);
|
|
185
186
|
let hit = false;
|
|
186
187
|
const hook = this.processor.debugInstruction.add((addr) => {
|
|
187
188
|
if (addr === idleAddr) {
|
|
@@ -250,19 +251,10 @@ export class TestMachine {
|
|
|
250
251
|
async loadBasic(source) {
|
|
251
252
|
const tokeniser = await Tokeniser.create();
|
|
252
253
|
const tokenised = tokeniser.tokenise(source);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
// Set VARTOP (0x12/3) and TOP(0x02/3)
|
|
259
|
-
const end = page + tokenised.length;
|
|
260
|
-
const endLow = end & 0xff;
|
|
261
|
-
const endHigh = (end >>> 8) & 0xff;
|
|
262
|
-
this.writebyte(0x02, endLow);
|
|
263
|
-
this.writebyte(0x03, endHigh);
|
|
264
|
-
this.writebyte(0x12, endLow);
|
|
265
|
-
this.writebyte(0x13, endHigh);
|
|
254
|
+
installBasic(tokenised, {
|
|
255
|
+
readByte: (addr) => this.readbyte(addr),
|
|
256
|
+
writeByte: (addr, value) => this.writebyte(addr, value),
|
|
257
|
+
});
|
|
266
258
|
}
|
|
267
259
|
|
|
268
260
|
/**
|