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.
@@ -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
+ }
@@ -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.isMaster ? 0xe7e6 : 0xe581;
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
- // TODO: dedupe from main.js
254
- const page = this.readbyte(0x18) << 8;
255
- for (let i = 0; i < tokenised.length; ++i) {
256
- this.writebyte(page + i, tokenised.charCodeAt(i));
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
  /**