jsbeeb 1.22.0 → 1.22.1

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,219 @@
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
+ export 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, isRunning, stop, go }) {
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.isRunning = isRunning;
96
+ this.stop = stop;
97
+ this.go = go;
98
+
99
+ document.getElementById("save-state").addEventListener("click", async (event) => {
100
+ event.preventDefault();
101
+ await this.saveState();
102
+ });
103
+
104
+ document.getElementById("load-state").addEventListener("change", async (event) => {
105
+ const file = event.target.files[0];
106
+ if (!file) return;
107
+ event.target.value = "";
108
+ await this.loadStateFromFile(file);
109
+ });
110
+ }
111
+
112
+ async saveState() {
113
+ const wasRunning = this.isRunning();
114
+ if (wasRunning) this.stop(false);
115
+ try {
116
+ const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params);
117
+ const snapshot = createSnapshot(this.processor, this.model, manifest);
118
+ const json = snapshotToJSON(snapshot);
119
+ const blob = await compressBlob(new Blob([json]));
120
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
121
+ downloadBlob(blob, `jsbeeb-${this.model.name}-${timestamp}.json.gz`);
122
+ } catch (e) {
123
+ this.modals.showError("saving state", e);
124
+ }
125
+ if (wasRunning) this.go();
126
+ }
127
+
128
+ async loadStateFromFile(file, preReadBuffer) {
129
+ const wasRunning = this.isRunning();
130
+ if (wasRunning) this.stop(false);
131
+ try {
132
+ const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
133
+ const snapshot = await readSnapshot(arrayBuffer);
134
+ if (!isSameModel(snapshot.model, this.model.name) || hasCoProcessor(snapshot) !== this.processor.hasTube) {
135
+ // Model or co-processor mismatch: stash state and reload with a matching machine
136
+ sessionStorage.setItem(PendingStateKey, snapshotToJSON(snapshot));
137
+ window.location.href = this.urlState.urlWith({
138
+ model: snapshot.model,
139
+ coProcessor: hasCoProcessor(snapshot),
140
+ });
141
+ return;
142
+ }
143
+ await this.restore(snapshot);
144
+ // Force a repaint so the display updates even while paused
145
+ this.video.paint();
146
+ } catch (e) {
147
+ this.modals.showError("loading state", e);
148
+ }
149
+ if (wasRunning) this.go();
150
+ }
151
+
152
+ /** Picks up the state a cross-model reload stashed, once the matching machine is up. */
153
+ async restorePendingState() {
154
+ const pendingState = sessionStorage.getItem(PendingStateKey);
155
+ if (!pendingState) return;
156
+ sessionStorage.removeItem(PendingStateKey);
157
+ try {
158
+ await this.restore(snapshotFromJSON(pendingState));
159
+ this.processor.execute(PostRestoreCycles);
160
+ } catch (e) {
161
+ this.modals.showError("restoring saved state", e);
162
+ }
163
+ }
164
+
165
+ async restore(snapshot) {
166
+ // Order matters: reload disc media first so the base disc is in the
167
+ // drive before restoreSnapshot applies dirty track overlays on top.
168
+ await this.reloadSnapshotMedia(snapshot.media);
169
+ restoreSnapshot(this.processor, this.model, snapshot);
170
+ }
171
+
172
+ async reloadSnapshotMedia(savedMedia) {
173
+ if (!savedMedia) return;
174
+ for (let driveIndex = 0; driveIndex < 2; driveIndex++) {
175
+ const discKey = driveIndex === 0 ? "disc1" : "disc2";
176
+ const imageDataKey = discKey + "ImageData";
177
+ const crcKey = discKey + "Crc32";
178
+
179
+ // A snapshot from before layout detection has no field, and was contiguous.
180
+ const layout = savedMedia[discKey + "Layout"] ?? DiscLayout.contiguous;
181
+
182
+ let loadedDisc = null;
183
+ if (savedMedia[discKey]) {
184
+ // URL-based disc — reload from source
185
+ loadedDisc = await this.media.loadDiscImage(savedMedia[discKey], layout);
186
+ } else if (savedMedia[imageDataKey]) {
187
+ // Locally-loaded disc — reconstruct from embedded image data
188
+ const imageData =
189
+ savedMedia[imageDataKey] instanceof Uint8Array
190
+ ? savedMedia[imageDataKey]
191
+ : new Uint8Array(Object.values(savedMedia[imageDataKey]));
192
+ const discName = savedMedia[discKey + "Name"] || "snapshot.ssd";
193
+ loadedDisc = disc.discFor(this.processor.fdc, discName, imageData, undefined, layout);
194
+ // Retain the image bytes so subsequent saves can re-embed them.
195
+ loadedDisc.setOriginalImage(imageData);
196
+ }
197
+ if (!loadedDisc) continue;
198
+
199
+ // Verify CRC32 if present
200
+ if (savedMedia[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
201
+ if (loadedDisc.originalImageCrc32 !== savedMedia[crcKey]) {
202
+ toast(
203
+ `${loadedDisc.name} has changed since this state was saved. The state has been restored anyway and may not run correctly.`,
204
+ { title: "Restoring state" },
205
+ );
206
+ }
207
+ }
208
+
209
+ this.drives.putDiscIn(driveIndex, loadedDisc);
210
+ // Only update the URL/query for URL-sourced discs. For embedded
211
+ // (local-file) discs, setting parsedQuery would put a bogus source
212
+ // in the URL and break subsequent saves/reloads.
213
+ if (savedMedia[discKey]) {
214
+ if (driveIndex === 0) this.media.setDisc1Image(savedMedia[discKey]);
215
+ else this.media.setDisc2Image(savedMedia[discKey]);
216
+ }
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,125 @@
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
+ const startLoad = () => showArchiveMessage("sth", "sth-list", "Loading catalog from STH archive");
26
+ const onError = () => showArchiveMessage("sth", "sth-list", "There was an error accessing the STH archive");
27
+ this.discs = new StairwayToHell(
28
+ startLoad,
29
+ (cat) => this.renderCatalogue(cat, (item) => this.pickDisc(item)),
30
+ onError,
31
+ false,
32
+ );
33
+ this.tapes = new StairwayToHell(
34
+ startLoad,
35
+ (cat) => this.renderCatalogue(cat, (item) => this.pickTape(item)),
36
+ onError,
37
+ true,
38
+ );
39
+
40
+ document.addEventListener("click", (e) => {
41
+ const target = e.target.closest("a.sth");
42
+ if (!target) return;
43
+ const type = target.dataset.id;
44
+ if (type === "discs") {
45
+ this.discs.populate();
46
+ } else if (type === "tapes") {
47
+ this.tapes.populate();
48
+ } else {
49
+ console.log("unknown id", type);
50
+ }
51
+ });
52
+
53
+ const sthFilter = document.getElementById("sth-filter");
54
+ const applyFilter = () => filterArchiveList("sth-list", sthFilter.value);
55
+ sthFilter.addEventListener("change", applyFilter);
56
+ sthFilter.addEventListener("keyup", applyFilter);
57
+ }
58
+
59
+ async pickDisc(item) {
60
+ utils.noteEvent("sth", "click", item);
61
+ this.media.setDisc1Image("sth:" + item);
62
+ const needsAutoboot = this.urlState.params.autoboot !== undefined;
63
+ if (needsAutoboot) {
64
+ this.processor.reset(true);
65
+ }
66
+
67
+ this.modals.popupLoading("Loading " + item);
68
+ try {
69
+ const loaded = await this.media.loadDiscImage(this.urlState.params.disc1, this.drives.layoutForDrive(0));
70
+ this.drives.putDiscIn(0, loaded);
71
+ this.modals.loadingFinished();
72
+
73
+ if (needsAutoboot) {
74
+ this.autoboot(item);
75
+ }
76
+ } catch (err) {
77
+ console.error("Error loading disc image:", err);
78
+ this.modals.loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
79
+ }
80
+ }
81
+
82
+ async pickTape(item) {
83
+ utils.noteEvent("sth", "clickTape", item);
84
+ this.media.setTapeImage("sth:" + item);
85
+
86
+ this.modals.popupLoading("Loading " + item);
87
+ try {
88
+ const tape = await this.media.loadTapeImage(this.urlState.params.tape);
89
+ this.media.setProcessorTape(tape);
90
+ this.modals.loadingFinished();
91
+ } catch (err) {
92
+ console.error("Error loading tape image:", err);
93
+ this.modals.loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
94
+ }
95
+ }
96
+
97
+ renderCatalogue(cat, onClick) {
98
+ clearArchiveList("sth-list");
99
+ const sthList = document.getElementById("sth-list");
100
+ document.querySelector("#sth .loading").style.display = "none";
101
+ const template = sthList.querySelector(".template");
102
+
103
+ const doSome = (all) => {
104
+ const MaxAtATime = 100;
105
+ const Delay = 30;
106
+ const batch = all.slice(0, MaxAtATime);
107
+ const remaining = all.slice(MaxAtATime);
108
+ const filter = document.getElementById("sth-filter").value;
109
+ for (const name of batch) {
110
+ const row = template.cloneNode(true);
111
+ row.classList.remove("template");
112
+ sthList.appendChild(row);
113
+ row.querySelector(".name").textContent = name;
114
+ row.addEventListener("click", () => {
115
+ onClick(name);
116
+ this.modal.hide();
117
+ });
118
+ row.style.display = name.toLowerCase().indexOf(filter) >= 0 ? "" : "none";
119
+ }
120
+ if (all.length) setTimeout(() => doSome(remaining), Delay);
121
+ };
122
+
123
+ doSome(cat);
124
+ }
125
+ }
@@ -0,0 +1,83 @@
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
+ fakeVideo: ParamTypes.BOOL,
22
+ logFdcCommands: ParamTypes.BOOL,
23
+ logFdcStateChanges: ParamTypes.BOOL,
24
+ coProcessor: ParamTypes.BOOL,
25
+ mouseJoystickEnabled: ParamTypes.BOOL,
26
+ speechOutput: ParamTypes.BOOL,
27
+ audioDebug: ParamTypes.BOOL,
28
+
29
+ // Numeric parameters
30
+ speed: ParamTypes.INT,
31
+ stationId: ParamTypes.INT,
32
+ frameSkip: ParamTypes.INT,
33
+ audiofilterfreq: ParamTypes.FLOAT,
34
+ audiofilterq: ParamTypes.FLOAT,
35
+ speakerAmount: ParamTypes.FLOAT,
36
+ audioLatencyMs: ParamTypes.FLOAT,
37
+ cpuMultiplier: ParamTypes.FLOAT,
38
+ tubeCpuMultiplier: ParamTypes.FLOAT,
39
+ microphoneChannel: ParamTypes.INT,
40
+
41
+ // String parameters (these are the default but listed for clarity)
42
+ model: ParamTypes.STRING,
43
+ disc: ParamTypes.STRING,
44
+ disc1: ParamTypes.STRING,
45
+ disc2: ParamTypes.STRING,
46
+ tape: ParamTypes.STRING,
47
+ mmc: ParamTypes.STRING,
48
+ keyLayout: ParamTypes.STRING,
49
+ autotype: ParamTypes.STRING,
50
+ displayMode: ParamTypes.STRING,
51
+ audioOutput: ParamTypes.STRING,
52
+ drive0Tracks: ParamTypes.STRING,
53
+ drive1Tracks: ParamTypes.STRING,
54
+ };
55
+
56
+ /**
57
+ * The page's settings as carried in its URL. `params` is the one parsed
58
+ * object: whoever changes a setting edits it in place and calls updateUrl.
59
+ */
60
+ export class UrlState {
61
+ constructor(location, history, paramTypes = UrlParamTypes) {
62
+ this.history = history;
63
+ this.paramTypes = paramTypes;
64
+ this.baseUrl = location.origin + location.pathname;
65
+ // Parameters may be given after the hash as well as in the query.
66
+ const queryString = location.search.substring(1) + "&" + location.hash.substring(1);
67
+ this.params = parseQueryString(queryString, paramTypes);
68
+ }
69
+
70
+ /** The page's URL with the parameters as they are now. */
71
+ url() {
72
+ return buildUrlFromParams(this.baseUrl, this.params, this.paramTypes);
73
+ }
74
+
75
+ /** The page's URL with some parameters changed, leaving `params` as it is. */
76
+ urlWith(overrides) {
77
+ return buildUrlFromParams(this.baseUrl, { ...this.params, ...overrides }, this.paramTypes);
78
+ }
79
+
80
+ updateUrl() {
81
+ this.history.pushState(null, null, this.url());
82
+ }
83
+ }
@@ -1,3 +1,4 @@
1
+ import { 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";
@@ -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
  /**