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,359 @@
1
+ import * as utils from "../utils.js";
2
+ import * as disc from "../fdc.js";
3
+ import { DiscLayout } from "../disc.js";
4
+ import { loadTapeFromData } from "../tapes.js";
5
+ import { toast } from "./toast.js";
6
+ import { errorText, reportIgnoredFiles, reportLoadFailure, unzipAndReport } from "./reporting.js";
7
+
8
+ /** The images offered on the Discs dialog's built-in list. */
9
+ export const BuiltInImages = [
10
+ {
11
+ name: "Elite",
12
+ desc: "An 8-bit classic. Hit F10 to launch from the space station, then use <, >, S, X and A to fly around.",
13
+ file: "elite.ssd",
14
+ },
15
+ {
16
+ name: "Welcome",
17
+ desc: "The disc supplied with BBC Disc systems to demonstrate some of the features of the system.",
18
+ file: "Welcome.ssd",
19
+ },
20
+ {
21
+ name: "Music 5000",
22
+ desc: "The Music 5000 system disk and demo songs.",
23
+ file: "5000mstr36008.ssd",
24
+ },
25
+ ];
26
+
27
+ export function splitImage(image) {
28
+ const match = image.match(/(([^:]+):\/?\/?|[!^|])?(.*)/);
29
+ const schema = match[2] || match[1] || "";
30
+ image = match[3];
31
+ return { image: image, schema: schema };
32
+ }
33
+
34
+ function readFileAsBinaryString(file) {
35
+ return new Promise((resolve, reject) => {
36
+ const reader = new FileReader();
37
+ reader.onload = (e) => {
38
+ resolve(e.target.result);
39
+ };
40
+ reader.onerror = (e) => {
41
+ console.error(`Error reading file ${file.name}:`, e);
42
+ reject(new Error(`Failed to read file ${file.name}`));
43
+ };
44
+ reader.readAsBinaryString(file);
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Getting discs and tapes into the machine: resolving any image reference the
50
+ * URL schema can name, the local file inputs, the drop zone and the built-in
51
+ * list. Choosing what goes in a drive funnels through drives.putDiscIn.
52
+ */
53
+ export class MediaLoader {
54
+ /**
55
+ * @param {object} deps
56
+ * @param {object} deps.sources fetchers keyed by schema: sth, tapeSth and hfe
57
+ * resolve an archive name to image data; drive loads a Google Drive file
58
+ * @param {Function} deps.isSnapshotFile says whether a dropped file is a save state
59
+ * @param {Function} deps.loadSnapshot restores a dropped save state
60
+ */
61
+ constructor({ processor, model, drives, urlState, config, modals, sources, isSnapshotFile, loadSnapshot }) {
62
+ this.processor = processor;
63
+ this.model = model;
64
+ this.drives = drives;
65
+ this.urlState = urlState;
66
+ this.config = config;
67
+ this.modals = modals;
68
+ this.sources = sources;
69
+
70
+ document.getElementById("disc_load").addEventListener("change", async (evt) => {
71
+ if (evt.target.files.length === 0) return;
72
+ utils.noteEvent("local", "click"); // NB no filename here
73
+ const file = evt.target.files[0];
74
+ try {
75
+ await this.loadHTMLFile(file);
76
+ } catch (error) {
77
+ reportLoadFailure(file.name, error);
78
+ }
79
+ evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
80
+ });
81
+
82
+ document.getElementById("fs_load").addEventListener("change", async (evt) => {
83
+ if (evt.target.files.length === 0) return;
84
+ utils.noteEvent("local", "click"); // NB no filename here
85
+ const file = evt.target.files[0];
86
+ try {
87
+ await this.loadSCSIFile(file);
88
+ } catch (error) {
89
+ reportLoadFailure(file.name, error);
90
+ }
91
+ evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
92
+ });
93
+
94
+ document.getElementById("tape_load").addEventListener("change", async (evt) => {
95
+ if (evt.target.files.length === 0) return;
96
+ const file = evt.target.files[0];
97
+ utils.noteEvent("local", "clickTape"); // NB no filename here
98
+
99
+ try {
100
+ let tapeData = await readFileAsBinaryString(file);
101
+ let tapeName = file.name;
102
+ if (/\.zip/i.test(tapeName)) {
103
+ const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
104
+ tapeData = unzipped.data;
105
+ tapeName = unzipped.name;
106
+ }
107
+ this.setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
108
+ delete this.params.tape;
109
+ urlState.updateUrl();
110
+ modals.hide("tapes");
111
+ } catch (error) {
112
+ reportLoadFailure(file.name, error);
113
+ }
114
+
115
+ evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
116
+ });
117
+
118
+ const pastetext = document.getElementById("paste-text");
119
+ pastetext.addEventListener("dragover", (event) => {
120
+ event.preventDefault();
121
+ event.stopPropagation();
122
+ event.dataTransfer.dropEffect = "copy";
123
+ });
124
+ pastetext.addEventListener("drop", async (event) => {
125
+ utils.noteEvent("local", "drop");
126
+ const file = event.dataTransfer.files[0];
127
+ if (!file) return;
128
+ try {
129
+ const arrayBuffer = await file.arrayBuffer();
130
+ if (isSnapshotFile(file.name, arrayBuffer)) {
131
+ await loadSnapshot(file, arrayBuffer);
132
+ } else if (file.name.toLowerCase().endsWith(".uef")) {
133
+ // Regular UEF tape image (not a BeebEm save state)
134
+ this.setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
135
+ toast(`Loaded ${file.name} as the tape.`, { title: "Dropped" });
136
+ } else {
137
+ await this.loadHTMLFile(file);
138
+ toast(`Loaded ${file.name} into drive 0.`, { title: "Dropped" });
139
+ }
140
+ } catch (error) {
141
+ reportLoadFailure(file.name, error);
142
+ }
143
+ });
144
+
145
+ const discList = document.getElementById("disc-list");
146
+ const discTemplate = discList.querySelector(".template");
147
+ for (const image of BuiltInImages) {
148
+ const elem = discTemplate.cloneNode(true);
149
+ elem.classList.remove("template");
150
+ discList.appendChild(elem);
151
+ elem.querySelector(".name").textContent = image.name;
152
+ elem.querySelector(".description").textContent = image.desc;
153
+ elem.addEventListener("click", async () => {
154
+ utils.noteEvent("images", "click", image.file);
155
+ this.setDisc1Image(image.file);
156
+ modals.hide("discs");
157
+ try {
158
+ drives.putDiscIn(0, await this.loadDiscImage(this.params.disc1, drives.layoutForDrive(0)));
159
+ } catch (error) {
160
+ reportLoadFailure(`${image.name} (${image.file})`, error);
161
+ }
162
+ });
163
+ }
164
+ }
165
+
166
+ get params() {
167
+ return this.urlState.params;
168
+ }
169
+
170
+ /** Route tape to the correct interface (ACIA for BBC, PPIA for Atom) */
171
+ setProcessorTape(tape) {
172
+ if (this.model.isAtom) {
173
+ this.processor.atomppia.setTape(tape);
174
+ } else {
175
+ this.processor.acia.setTape(tape);
176
+ }
177
+ }
178
+
179
+ setDisc1Image(name) {
180
+ delete this.params.disc;
181
+ this.params.disc1 = name;
182
+ this.urlState.updateUrl();
183
+ this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
184
+ }
185
+
186
+ setDisc2Image(name) {
187
+ this.params.disc2 = name;
188
+ this.urlState.updateUrl();
189
+ this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
190
+ }
191
+
192
+ setTapeImage(name) {
193
+ this.params.tape = name;
194
+ this.urlState.updateUrl();
195
+ this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
196
+ }
197
+
198
+ async loadHTMLFile(file) {
199
+ const imageData = utils.stringToUint8Array(await readFileAsBinaryString(file));
200
+ const loadedDisc = disc.discFor(
201
+ this.processor.fdc,
202
+ file.name,
203
+ imageData,
204
+ undefined,
205
+ this.drives.layoutForDrive(0),
206
+ );
207
+ // Local file: retain the image bytes for embedding in save-to-file snapshots.
208
+ loadedDisc.setOriginalImage(imageData);
209
+ this.drives.putDiscIn(0, loadedDisc);
210
+ delete this.params.disc;
211
+ delete this.params.disc1;
212
+ this.urlState.updateUrl();
213
+ this.modals.hide("discs");
214
+ }
215
+
216
+ async loadSCSIFile(file) {
217
+ const binaryData = await readFileAsBinaryString(file);
218
+ const { processor } = this;
219
+ processor.filestore.scsi = utils.stringToUint8Array(binaryData);
220
+
221
+ processor.filestore.PC = 0x400;
222
+ processor.filestore.SP = 0xff;
223
+ processor.filestore.A = 1;
224
+ processor.filestore.emulationSpeed = 0;
225
+
226
+ // Reset any open receive blocks
227
+ processor.econet.receiveBlocks = [];
228
+ processor.econet.nextReceiveBlockNumber = 1;
229
+
230
+ this.modals.hide("econetfs");
231
+ }
232
+
233
+ async loadDiscImage(discImage, layout = DiscLayout.auto) {
234
+ if (!discImage) return null;
235
+ const split = splitImage(discImage);
236
+ discImage = split.image;
237
+ const schema = split.schema;
238
+ if (schema[0] === "!" || schema === "local") {
239
+ return disc.localDisc(this.processor.fdc, discImage, layout, (error) =>
240
+ toast(
241
+ `Browser storage would not take changes to ${discImage} (${errorText(error)}). Use Discs, Download to keep a copy.`,
242
+ { title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
243
+ ),
244
+ );
245
+ }
246
+ // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
247
+ // Consider:
248
+ // * hashing contents and making a local disc image named by original disc hash, save by that, and offer
249
+ // to load the modified disc on load.
250
+ // * popping up a message that notes the disc has changed, and offers a way to make a local image
251
+ // * Dialog box (ugh) saying "is this ok?"
252
+ switch (schema) {
253
+ case "|":
254
+ case "sth": {
255
+ const { name, data, ignored } = await this.sources.sth(discImage);
256
+ reportIgnoredFiles(name, ignored);
257
+ return disc.discFor(this.processor.fdc, name, data, undefined, layout);
258
+ }
259
+
260
+ case "hfe":
261
+ return disc.discFor(
262
+ this.processor.fdc,
263
+ discImage,
264
+ await this.sources.hfe(discImage),
265
+ undefined,
266
+ layout,
267
+ );
268
+
269
+ case "gd": {
270
+ const splat = discImage.match(/([^/]+)\/?(.*)/);
271
+ let name = "(unknown)";
272
+ if (splat) {
273
+ discImage = splat[1];
274
+ name = splat[2];
275
+ }
276
+ return this.sources.drive({ name, id: discImage }, layout);
277
+ }
278
+ case "b64data":
279
+ return disc.discFor(this.processor.fdc, "disk.ssd", atob(discImage), undefined, layout);
280
+
281
+ case "data": {
282
+ const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
283
+ const { name, data } = await unzipAndReport(arr);
284
+ return disc.discFor(this.processor.fdc, name, data, undefined, layout);
285
+ }
286
+ case "http":
287
+ case "https":
288
+ case "file": {
289
+ const asUrl = `${schema}://${discImage}`;
290
+ // url may end in query params etc, which can upset the DSD/SSD etc detection on the extension.
291
+ discImage = new URL(asUrl).pathname;
292
+ let discData = await utils.loadData(asUrl);
293
+ if (/\.zip/i.test(discImage)) {
294
+ const unzipped = await unzipAndReport(discData);
295
+ discData = unzipped.data;
296
+ discImage = unzipped.name;
297
+ }
298
+ return disc.discFor(this.processor.fdc, discImage, discData, undefined, layout);
299
+ }
300
+ default:
301
+ return disc.discFor(
302
+ this.processor.fdc,
303
+ discImage,
304
+ await disc.load("discs/" + discImage),
305
+ undefined,
306
+ layout,
307
+ );
308
+ }
309
+ }
310
+
311
+ async loadTapeImage(tapeImage) {
312
+ if (!tapeImage) return null;
313
+ const split = splitImage(tapeImage);
314
+ tapeImage = split.image;
315
+ const schema = split.schema;
316
+
317
+ switch (schema) {
318
+ case "|":
319
+ case "sth": {
320
+ const { name, data, ignored } = await this.sources.tapeSth(tapeImage);
321
+ reportIgnoredFiles(name, ignored);
322
+ return await loadTapeFromData(name, data, this.model);
323
+ }
324
+
325
+ case "data": {
326
+ const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
327
+ const { name, data } = await unzipAndReport(arr);
328
+ return await loadTapeFromData(name, data, this.model);
329
+ }
330
+
331
+ case "http":
332
+ case "https":
333
+ case "file": {
334
+ const asUrl = `${schema}://${tapeImage}`;
335
+ // url may end in query params etc, which can upset file handling
336
+ tapeImage = new URL(asUrl).pathname;
337
+ let tapeData = await utils.loadData(asUrl);
338
+ if (/\.zip/i.test(tapeImage)) {
339
+ const unzipped = await unzipAndReport(tapeData);
340
+ tapeData = unzipped.data;
341
+ tapeImage = unzipped.name;
342
+ }
343
+ return await loadTapeFromData(tapeImage, tapeData, this.model);
344
+ }
345
+
346
+ default: {
347
+ const tapePath = "tapes/" + tapeImage;
348
+ let tapeData = await utils.loadData(tapePath);
349
+ let tapeName = tapeImage;
350
+ if (/\.zip/i.test(tapeName)) {
351
+ const unzipped = await unzipAndReport(tapeData);
352
+ tapeData = unzipped.data;
353
+ tapeName = unzipped.name;
354
+ }
355
+ return await loadTapeFromData(tapeName, tapeData, this.model);
356
+ }
357
+ }
358
+ }
359
+ }
@@ -0,0 +1,83 @@
1
+ import * as bootstrap from "bootstrap";
2
+ import { toast } from "./toast.js";
3
+
4
+ /**
5
+ * The dialogs the page raises itself, and the rule that a dialog pauses the
6
+ * emulator: the first one up stops it, and the last one down starts it again
7
+ * if it was running before.
8
+ */
9
+ export class Modals {
10
+ constructor({ loop }) {
11
+ this.errorDialog = document.getElementById("error-dialog");
12
+ this.errorModal = new bootstrap.Modal(this.errorDialog);
13
+ this.loadingDialog = document.getElementById("loading-dialog");
14
+ this.loadingModal = new bootstrap.Modal(this.loadingDialog);
15
+ this.googleDriveAuth = document.getElementById("google-drive-auth");
16
+ this.aysEl = document.getElementById("are-you-sure");
17
+ this.aysModal = new bootstrap.Modal(this.aysEl);
18
+
19
+ let savedRunning = false;
20
+ document.addEventListener("show.bs.modal", () => {
21
+ if (!this.anyVisible()) savedRunning = loop.isRunning();
22
+ if (loop.isRunning()) loop.stop(false);
23
+ });
24
+ document.addEventListener("hidden.bs.modal", () => {
25
+ if (!this.anyVisible() && savedRunning) loop.go();
26
+ });
27
+ }
28
+
29
+ anyVisible() {
30
+ return document.querySelectorAll(".modal.show").length !== 0;
31
+ }
32
+
33
+ show(id) {
34
+ const el = document.getElementById(id);
35
+ if (el) bootstrap.Modal.getOrCreateInstance(el).show();
36
+ }
37
+
38
+ hide(id) {
39
+ const el = document.getElementById(id);
40
+ if (el) bootstrap.Modal.getInstance(el)?.hide();
41
+ }
42
+
43
+ showError(context, error) {
44
+ this.errorDialog.querySelector(".context").textContent = context;
45
+ this.errorDialog.querySelector(".error").textContent = error;
46
+ this.errorModal.show();
47
+ }
48
+
49
+ popupLoading(msg) {
50
+ this.loadingDialog.querySelector(".loading").textContent = msg;
51
+ this.googleDriveAuth.style.display = "none";
52
+ this.loadingModal.show();
53
+ }
54
+
55
+ loadingFinished(message) {
56
+ this.googleDriveAuth.style.display = "none";
57
+ this.loadingModal.hide();
58
+ if (message) toast(message);
59
+ }
60
+
61
+ areYouSure(message, yesText, noText, yesFunc) {
62
+ const yesButton = this.aysEl.querySelector(".ays-yes");
63
+ this.aysEl.querySelector(".context").textContent = message;
64
+ this.aysEl.querySelector(".ays-no").textContent = noText;
65
+ yesButton.textContent = yesText;
66
+ let confirmed = false;
67
+ const onYes = () => {
68
+ confirmed = true;
69
+ this.aysModal.hide();
70
+ };
71
+ yesButton.addEventListener("click", onYes, { once: true });
72
+ // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
73
+ this.aysEl.addEventListener(
74
+ "hidden.bs.modal",
75
+ () => {
76
+ yesButton.removeEventListener("click", onYes);
77
+ if (confirmed) yesFunc();
78
+ },
79
+ { once: true },
80
+ );
81
+ this.aysModal.show();
82
+ }
83
+ }
@@ -0,0 +1,28 @@
1
+ import * as utils from "../utils.js";
2
+ import { toast } from "./toast.js";
3
+
4
+ export const errorText = (error) => error?.message ?? `${error}`;
5
+
6
+ export function reportLoadFailure(description, error) {
7
+ console.error(`Error loading ${description}:`, error);
8
+ toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
9
+ }
10
+
11
+ export function reportIgnoredFiles(name, ignored) {
12
+ if (!ignored.length) return;
13
+ toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
14
+ title: "Archive",
15
+ });
16
+ }
17
+
18
+ export async function unzipAndReport(data) {
19
+ const unzipped = await utils.unzipDiscImage(data);
20
+ reportIgnoredFiles(unzipped.name, unzipped.ignored);
21
+ return unzipped;
22
+ }
23
+
24
+ /** Handles a component's "notice" event by toasting it. */
25
+ export function showNotice(event) {
26
+ const { message, title, quietKey } = event.detail;
27
+ toast(message, { title, quietKey });
28
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- import { renderThumbnails, executeUntilFrame } from "./rewind-thumbnail.js";
3
+ import { renderThumbnails, executeUntilFrame } from "../rewind-thumbnail.js";
4
4
 
5
5
  /**
6
6
  * Rewind scrubber UI — a filmstrip overlay showing thumbnails of recent
@@ -13,18 +13,14 @@ export class RewindUI {
13
13
  * @param {object} options.processor - Cpu6502 instance
14
14
  * @param {object} options.video - Video instance
15
15
  * @param {number} options.captureInterval - rewind capture interval in frames
16
- * @param {function} options.stop - function to pause the emulator
17
- * @param {function} options.go - function to resume the emulator
18
- * @param {function} options.isRunning - function returning current running state
16
+ * @param {object} options.loop - the emulation loop, for pausing and resuming
19
17
  */
20
- constructor({ rewindBuffer, processor, video, captureInterval, stop, go, isRunning }) {
18
+ constructor({ rewindBuffer, processor, video, captureInterval, loop }) {
21
19
  this.rewindBuffer = rewindBuffer;
22
20
  this.processor = processor;
23
21
  this.video = video;
24
22
  this.captureInterval = captureInterval;
25
- this.stop = stop;
26
- this.go = go;
27
- this.isRunning = isRunning;
23
+ this.loop = loop;
28
24
 
29
25
  this.panel = document.getElementById("rewind-panel");
30
26
  this.filmstrip = document.getElementById("rewind-filmstrip");
@@ -45,6 +41,13 @@ export class RewindUI {
45
41
  });
46
42
  }
47
43
 
44
+ /** Forget everything captured, as on a hard reset. */
45
+ reset() {
46
+ this.close();
47
+ this.rewindBuffer.clear();
48
+ this.updateButtonState();
49
+ }
50
+
48
51
  /** Open the rewind scrubber panel. */
49
52
  open() {
50
53
  if (this.isOpen) return;
@@ -52,8 +55,8 @@ export class RewindUI {
52
55
  this.snapshots = this.rewindBuffer.getAll();
53
56
  if (this.snapshots.length === 0) return;
54
57
 
55
- this.wasRunning = this.isRunning();
56
- if (this.wasRunning) this.stop(false);
58
+ this.wasRunning = this.loop.isRunning();
59
+ if (this.wasRunning) this.loop.stop(false);
57
60
 
58
61
  this.isOpen = true;
59
62
  this.savedState = this.processor.snapshotState();
@@ -70,7 +73,7 @@ export class RewindUI {
70
73
  } catch (e) {
71
74
  this.processor.restoreState(this.savedState);
72
75
  this._closePanel();
73
- if (this.wasRunning) this.go();
76
+ if (this.wasRunning) this.loop.go();
74
77
  throw e;
75
78
  }
76
79
 
@@ -97,7 +100,7 @@ export class RewindUI {
97
100
  this.processor.restoreState(this.snapshots[this.selectedIndex]);
98
101
  }
99
102
  this._closePanel();
100
- if (this.wasRunning) this.go();
103
+ if (this.wasRunning) this.loop.go();
101
104
  }
102
105
 
103
106
  /**
@@ -107,7 +110,7 @@ export class RewindUI {
107
110
  if (!this.isOpen) return;
108
111
  this._renderState(this.savedState);
109
112
  this._closePanel();
110
- if (this.wasRunning) this.go();
113
+ if (this.wasRunning) this.loop.go();
111
114
  }
112
115
 
113
116
  /** Alias for cancel — closing the panel without explicit commit cancels. */