jsbeeb 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +2 -2
  2. package/package.json +14 -10
  3. package/src/6502.js +39 -42
  4. package/src/6502.opcodes.js +7 -13
  5. package/src/6847.js +4 -5
  6. package/src/6847_fontdata.js +0 -2
  7. package/src/acia.js +2 -3
  8. package/src/app/app.js +0 -1
  9. package/src/app/args.js +0 -2
  10. package/src/app/electron.js +4 -6
  11. package/src/app-bench.js +1 -3
  12. package/src/archive.js +243 -0
  13. package/src/basic-loader.js +0 -7
  14. package/src/basic-tokenise.js +19 -6
  15. package/src/bbc-palette.js +0 -2
  16. package/src/bbcdiscs.js +0 -2
  17. package/src/bem-snapshot.js +1 -3
  18. package/src/binary.js +175 -0
  19. package/src/cmos.js +0 -2
  20. package/src/ddnoise.js +0 -1
  21. package/src/debounce.js +7 -0
  22. package/src/disc-surface.js +0 -2
  23. package/src/disc.js +10 -9
  24. package/src/fake6502.js +7 -23
  25. package/src/fdc.js +9 -51
  26. package/src/filestore.js +10 -17
  27. package/src/hex.js +38 -0
  28. package/src/index.js +11 -0
  29. package/src/intel-fdc.js +18 -20
  30. package/src/{utils_atom.js → keymap-atom.js} +2 -18
  31. package/src/{utils.js → keymap.js} +47 -610
  32. package/src/loader.js +63 -0
  33. package/src/machine-session.js +37 -177
  34. package/src/machine-spec.js +59 -0
  35. package/src/main.js +66 -180
  36. package/src/media-resolver.js +68 -0
  37. package/src/mmc.js +3 -2
  38. package/src/models.js +17 -3
  39. package/src/music5000.js +0 -2
  40. package/src/ppia.js +3 -4
  41. package/src/printer.js +7 -14
  42. package/src/relaynoise.js +0 -1
  43. package/src/rewind.js +0 -2
  44. package/src/sample-player.js +2 -3
  45. package/src/serial.js +0 -2
  46. package/src/snapshot-helpers.js +0 -2
  47. package/src/snapshot.js +0 -2
  48. package/src/soundchip.js +1 -1
  49. package/src/state-utils.js +0 -2
  50. package/src/sth.js +2 -4
  51. package/src/tapes.js +10 -4
  52. package/src/teletext.js +2 -3
  53. package/src/teletext_adaptor.js +2 -4
  54. package/src/teletext_data.js +0 -2
  55. package/src/test-machine.js +307 -0
  56. package/src/touchscreen.js +2 -4
  57. package/src/tube.js +5 -6
  58. package/src/typist.js +103 -0
  59. package/src/uef-snapshot.js +0 -2
  60. package/src/url-params.js +4 -5
  61. package/src/vdu-capture.js +113 -0
  62. package/src/via.js +3 -4
  63. package/src/video-filters/pal-composite.js +0 -2
  64. package/src/video-filters/passthrough-filter.js +0 -2
  65. package/src/video-filters/pixel-grid.js +0 -2
  66. package/src/video-filters/shader-program.js +0 -2
  67. package/src/video-filters/xbr-filter.js +0 -2
  68. package/src/video.js +12 -11
  69. package/src/wd-fdc.js +20 -20
  70. package/src/web/analogue-inputs.js +6 -8
  71. package/src/web/analytics.js +13 -0
  72. package/src/web/audio-handler.js +2 -2
  73. package/src/{audio-utils.js → web/audio-utils.js} +0 -2
  74. package/src/web/autoboot.js +10 -13
  75. package/src/{canvas.js → web/canvas.js} +4 -5
  76. package/src/web/config.js +41 -74
  77. package/src/web/console-surface.js +25 -0
  78. package/src/web/debug.js +6 -5
  79. package/src/web/disc-visualiser.js +0 -2
  80. package/src/web/display.js +21 -4
  81. package/src/{dom-utils.js → web/dom-utils.js} +1 -11
  82. package/src/web/drives.js +1 -1
  83. package/src/web/emulation-loop.js +54 -33
  84. package/src/web/front-panel.js +13 -12
  85. package/src/{gamepads.js → web/gamepads.js} +2 -6
  86. package/src/web/google-drive-picker.js +14 -9
  87. package/src/{google-drive.js → web/google-drive.js} +9 -10
  88. package/src/web/hfe-picker.js +5 -4
  89. package/src/web/keyboard-setup.js +18 -38
  90. package/src/{keyboard.js → web/keyboard.js} +18 -119
  91. package/src/web/layout.js +4 -0
  92. package/src/web/local-disc.js +44 -0
  93. package/src/web/machine.js +27 -20
  94. package/src/web/media-loader.js +38 -147
  95. package/src/{microphone-input.js → web/microphone-input.js} +1 -1
  96. package/src/web/modals.js +6 -9
  97. package/src/web/page-actions.js +88 -0
  98. package/src/web/quick-settings.js +16 -15
  99. package/src/web/reporting.js +0 -7
  100. package/src/{rewind-thumbnail.js → web/rewind-thumbnail.js} +0 -2
  101. package/src/web/rewind-ui.js +29 -29
  102. package/src/web/run-controls.js +39 -0
  103. package/src/web/settings.js +64 -135
  104. package/src/web/snapshot-ui.js +10 -16
  105. package/src/{speech-output.js → web/speech-output.js} +0 -2
  106. package/src/web/sth-picker.js +9 -7
  107. package/src/web/url-state.js +20 -6
  108. package/src/econet/L3FS.dat +0 -0
  109. package/src/econet/scsi.dat +0 -0
  110. package/tests/test-machine.js +0 -609
@@ -1,9 +1,12 @@
1
- import * as utils from "../utils.js";
2
1
  import * as disc from "../fdc.js";
2
+ import { localDisc } from "./local-disc.js";
3
3
  import { DiscLayout } from "../disc.js";
4
4
  import { loadTapeFromData } from "../tapes.js";
5
5
  import { toast } from "./toast.js";
6
- import { errorText, reportIgnoredFiles, reportLoadFailure, unzipAndReport } from "./reporting.js";
6
+ import { errorText, reportIgnoredFiles, reportLoadFailure } from "./reporting.js";
7
+ import { MediaResolver, openIfZip, splitImage } from "../media-resolver.js";
8
+ import { stringToUint8Array } from "../binary.js";
9
+ import { noteEvent } from "./analytics.js";
7
10
 
8
11
  /** The images offered on the Discs dialog's built-in list. */
9
12
  export const BuiltInImages = [
@@ -24,13 +27,6 @@ export const BuiltInImages = [
24
27
  },
25
28
  ];
26
29
 
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
30
  function readFileAsBinaryString(file) {
35
31
  return new Promise((resolve, reject) => {
36
32
  const reader = new FileReader();
@@ -63,11 +59,12 @@ export class MediaLoader extends EventTarget {
63
59
  this.drives = drives;
64
60
  this.urlState = urlState;
65
61
  this.modals = modals;
66
- this.sources = {};
62
+ this.resolver = new MediaResolver();
63
+ this.driveSource = null;
67
64
 
68
65
  document.getElementById("disc_load").addEventListener("change", async (evt) => {
69
66
  if (evt.target.files.length === 0) return;
70
- utils.noteEvent("local", "click"); // NB no filename here
67
+ noteEvent("local", "click"); // NB no filename here
71
68
  const file = evt.target.files[0];
72
69
  try {
73
70
  await this.loadHTMLFile(file);
@@ -79,7 +76,7 @@ export class MediaLoader extends EventTarget {
79
76
 
80
77
  document.getElementById("fs_load").addEventListener("change", async (evt) => {
81
78
  if (evt.target.files.length === 0) return;
82
- utils.noteEvent("local", "click"); // NB no filename here
79
+ noteEvent("local", "click"); // NB no filename here
83
80
  const file = evt.target.files[0];
84
81
  try {
85
82
  await this.loadSCSIFile(file);
@@ -92,17 +89,15 @@ export class MediaLoader extends EventTarget {
92
89
  document.getElementById("tape_load").addEventListener("change", async (evt) => {
93
90
  if (evt.target.files.length === 0) return;
94
91
  const file = evt.target.files[0];
95
- utils.noteEvent("local", "clickTape"); // NB no filename here
92
+ noteEvent("local", "clickTape"); // NB no filename here
96
93
 
97
94
  try {
98
- let tapeData = await readFileAsBinaryString(file);
99
- let tapeName = file.name;
100
- if (/\.zip/i.test(tapeName)) {
101
- const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
102
- tapeData = unzipped.data;
103
- tapeName = unzipped.name;
104
- }
105
- this.setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
95
+ const { name, data, ignored } = await openIfZip(
96
+ file.name,
97
+ stringToUint8Array(await readFileAsBinaryString(file)),
98
+ );
99
+ reportIgnoredFiles(name, ignored);
100
+ this.setProcessorTape(await loadTapeFromData(name, data, model));
106
101
  urlState.set({ tape: undefined });
107
102
  modals.hide("tapes");
108
103
  } catch (error) {
@@ -119,7 +114,7 @@ export class MediaLoader extends EventTarget {
119
114
  event.dataTransfer.dropEffect = "copy";
120
115
  });
121
116
  pastetext.addEventListener("drop", async (event) => {
122
- utils.noteEvent("local", "drop");
117
+ noteEvent("local", "drop");
123
118
  const file = event.dataTransfer.files[0];
124
119
  if (!file) return;
125
120
  try {
@@ -148,7 +143,7 @@ export class MediaLoader extends EventTarget {
148
143
  elem.querySelector(".name").textContent = image.name;
149
144
  elem.querySelector(".description").textContent = image.desc;
150
145
  elem.addEventListener("click", async () => {
151
- utils.noteEvent("images", "click", image.file);
146
+ noteEvent("images", "click", image.file);
152
147
  this.setDisc1Image(image.file);
153
148
  modals.hide("discs");
154
149
  try {
@@ -166,16 +161,12 @@ export class MediaLoader extends EventTarget {
166
161
 
167
162
  /** Register the fetcher behind an image schema; each picker calls this as it is constructed. */
168
163
  addSource(schema, fetcher) {
169
- this.sources[schema] = fetcher;
164
+ if (schema === "drive") this.driveSource = fetcher;
165
+ else this.resolver.addSource(schema, fetcher);
170
166
  }
171
167
 
172
- /** Route tape to the correct interface (ACIA for BBC, PPIA for Atom) */
173
168
  setProcessorTape(tape) {
174
- if (this.model.isAtom) {
175
- this.processor.atomppia.setTape(tape);
176
- } else {
177
- this.processor.acia.setTape(tape);
178
- }
169
+ this.processor.tapeInterface.setTape(tape);
179
170
  }
180
171
 
181
172
  setDisc1Image(name) {
@@ -194,14 +185,8 @@ export class MediaLoader extends EventTarget {
194
185
  }
195
186
 
196
187
  async loadHTMLFile(file) {
197
- const imageData = utils.stringToUint8Array(await readFileAsBinaryString(file));
198
- const loadedDisc = disc.discFor(
199
- this.processor.fdc,
200
- file.name,
201
- imageData,
202
- undefined,
203
- this.drives.layoutForDrive(0),
204
- );
188
+ const imageData = stringToUint8Array(await readFileAsBinaryString(file));
189
+ const loadedDisc = disc.discFor(file.name, imageData, undefined, this.drives.layoutForDrive(0));
205
190
  // Local file: retain the image bytes for embedding in save-to-file snapshots.
206
191
  loadedDisc.setOriginalImage(imageData);
207
192
  this.drives.putDiscIn(0, loadedDisc);
@@ -213,7 +198,7 @@ export class MediaLoader extends EventTarget {
213
198
  const { processor } = this;
214
199
  if (!processor.filestore) return;
215
200
  const binaryData = await readFileAsBinaryString(file);
216
- processor.filestore.scsi = utils.stringToUint8Array(binaryData);
201
+ processor.filestore.scsi = stringToUint8Array(binaryData);
217
202
 
218
203
  processor.filestore.PC = 0x400;
219
204
  processor.filestore.SP = 0xff;
@@ -229,128 +214,34 @@ export class MediaLoader extends EventTarget {
229
214
 
230
215
  async loadDiscImage(discImage, layout = DiscLayout.auto) {
231
216
  if (!discImage) return null;
232
- const split = splitImage(discImage);
233
- discImage = split.image;
234
- const schema = split.schema;
217
+ const { schema, image } = splitImage(discImage);
235
218
  if (schema[0] === "!" || schema === "local") {
236
- return disc.localDisc(this.processor.fdc, discImage, layout, (error) =>
219
+ return localDisc(image, layout, (error) =>
237
220
  toast(
238
- `Browser storage would not take changes to ${discImage} (${errorText(error)}). Use Discs, Download to keep a copy.`,
221
+ `Browser storage would not take changes to ${image} (${errorText(error)}). Use Discs, Download to keep a copy.`,
239
222
  { title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
240
223
  ),
241
224
  );
242
225
  }
243
- // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
226
+ if (schema === "gd") {
227
+ const [, id, name = "(unknown)"] = image.match(/([^/]+)\/?(.*)/) ?? [null, image];
228
+ return this.driveSource({ name, id }, layout);
229
+ }
230
+ // TODO(#822) come up with a decent UX for passing an 'onChange' parameter to each of these.
244
231
  // Consider:
245
232
  // * hashing contents and making a local disc image named by original disc hash, save by that, and offer
246
233
  // to load the modified disc on load.
247
234
  // * popping up a message that notes the disc has changed, and offers a way to make a local image
248
235
  // * Dialog box (ugh) saying "is this ok?"
249
- switch (schema) {
250
- case "|":
251
- case "sth": {
252
- const { name, data, ignored } = await this.sources.sth(discImage);
253
- reportIgnoredFiles(name, ignored);
254
- return disc.discFor(this.processor.fdc, name, data, undefined, layout);
255
- }
256
-
257
- case "hfe":
258
- return disc.discFor(
259
- this.processor.fdc,
260
- discImage,
261
- await this.sources.hfe(discImage),
262
- undefined,
263
- layout,
264
- );
265
-
266
- case "gd": {
267
- const splat = discImage.match(/([^/]+)\/?(.*)/);
268
- let name = "(unknown)";
269
- if (splat) {
270
- discImage = splat[1];
271
- name = splat[2];
272
- }
273
- return this.sources.drive({ name, id: discImage }, layout);
274
- }
275
- case "b64data":
276
- return disc.discFor(this.processor.fdc, "disk.ssd", atob(discImage), undefined, layout);
277
-
278
- case "data": {
279
- const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
280
- const { name, data } = await unzipAndReport(arr);
281
- return disc.discFor(this.processor.fdc, name, data, undefined, layout);
282
- }
283
- case "http":
284
- case "https":
285
- case "file": {
286
- const asUrl = `${schema}://${discImage}`;
287
- // url may end in query params etc, which can upset the DSD/SSD etc detection on the extension.
288
- discImage = new URL(asUrl).pathname;
289
- let discData = await utils.loadData(asUrl);
290
- if (/\.zip/i.test(discImage)) {
291
- const unzipped = await unzipAndReport(discData);
292
- discData = unzipped.data;
293
- discImage = unzipped.name;
294
- }
295
- return disc.discFor(this.processor.fdc, discImage, discData, undefined, layout);
296
- }
297
- default:
298
- return disc.discFor(
299
- this.processor.fdc,
300
- discImage,
301
- await disc.load("discs/" + discImage),
302
- undefined,
303
- layout,
304
- );
305
- }
236
+ const { name, data, ignored } = await this.resolver.resolve("disc", discImage);
237
+ reportIgnoredFiles(name, ignored);
238
+ return disc.discFor(name, data, undefined, layout);
306
239
  }
307
240
 
308
241
  async loadTapeImage(tapeImage) {
309
242
  if (!tapeImage) return null;
310
- const split = splitImage(tapeImage);
311
- tapeImage = split.image;
312
- const schema = split.schema;
313
-
314
- switch (schema) {
315
- case "|":
316
- case "sth": {
317
- const { name, data, ignored } = await this.sources.tapeSth(tapeImage);
318
- reportIgnoredFiles(name, ignored);
319
- return await loadTapeFromData(name, data, this.model);
320
- }
321
-
322
- case "data": {
323
- const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
324
- const { name, data } = await unzipAndReport(arr);
325
- return await loadTapeFromData(name, data, this.model);
326
- }
327
-
328
- case "http":
329
- case "https":
330
- case "file": {
331
- const asUrl = `${schema}://${tapeImage}`;
332
- // url may end in query params etc, which can upset file handling
333
- tapeImage = new URL(asUrl).pathname;
334
- let tapeData = await utils.loadData(asUrl);
335
- if (/\.zip/i.test(tapeImage)) {
336
- const unzipped = await unzipAndReport(tapeData);
337
- tapeData = unzipped.data;
338
- tapeImage = unzipped.name;
339
- }
340
- return await loadTapeFromData(tapeImage, tapeData, this.model);
341
- }
342
-
343
- default: {
344
- const tapePath = "tapes/" + tapeImage;
345
- let tapeData = await utils.loadData(tapePath);
346
- let tapeName = tapeImage;
347
- if (/\.zip/i.test(tapeName)) {
348
- const unzipped = await unzipAndReport(tapeData);
349
- tapeData = unzipped.data;
350
- tapeName = unzipped.name;
351
- }
352
- return await loadTapeFromData(tapeName, tapeData, this.model);
353
- }
354
- }
243
+ const { name, data, ignored } = await this.resolver.resolve("tape", tapeImage);
244
+ reportIgnoredFiles(name, ignored);
245
+ return loadTapeFromData(name, data, this.model);
355
246
  }
356
247
  }
@@ -1,4 +1,4 @@
1
- import { AnalogueSource } from "./analogue-source.js";
1
+ import { AnalogueSource } from "../analogue-source.js";
2
2
  import { createAudioContext } from "./audio-utils.js";
3
3
 
4
4
  /**
package/src/web/modals.js CHANGED
@@ -12,17 +12,16 @@ export class Modals {
12
12
  this.errorModal = new bootstrap.Modal(this.errorDialog);
13
13
  this.loadingDialog = document.getElementById("loading-dialog");
14
14
  this.loadingModal = new bootstrap.Modal(this.loadingDialog);
15
- this.googleDriveAuth = document.getElementById("google-drive-auth");
16
15
  this.aysEl = document.getElementById("are-you-sure");
17
16
  this.aysModal = new bootstrap.Modal(this.aysEl);
18
17
 
19
- let savedRunning = false;
20
- document.addEventListener("show.bs.modal", () => {
21
- if (!this.anyVisible()) savedRunning = loop.isRunning();
22
- if (loop.isRunning()) loop.stop(false);
18
+ const holds = new WeakMap();
19
+ document.addEventListener("show.bs.modal", (event) => {
20
+ if (!holds.has(event.target)) holds.set(event.target, loop.pause(`the ${event.target.id} dialog`));
23
21
  });
24
- document.addEventListener("hidden.bs.modal", () => {
25
- if (!this.anyVisible() && savedRunning) loop.go();
22
+ document.addEventListener("hidden.bs.modal", (event) => {
23
+ holds.get(event.target)?.();
24
+ holds.delete(event.target);
26
25
  });
27
26
  }
28
27
 
@@ -48,12 +47,10 @@ export class Modals {
48
47
 
49
48
  popupLoading(msg) {
50
49
  this.loadingDialog.querySelector(".loading").textContent = msg;
51
- this.googleDriveAuth.style.display = "none";
52
50
  this.loadingModal.show();
53
51
  }
54
52
 
55
53
  loadingFinished(message) {
56
- this.googleDriveAuth.style.display = "none";
57
54
  this.loadingModal.hide();
58
55
  if (message) toast(message);
59
56
  }
@@ -0,0 +1,88 @@
1
+ import { downloadDriveData } from "./dom-utils.js";
2
+
3
+ const UnloadWarning =
4
+ "It seems like you're still using the emulator. If you're in Chrome, it's impossible for jsbeeb to prevent some shortcuts (like ctrl-W) from performing their default behaviour (e.g. closing the window).\n" +
5
+ "As a workaround, create an 'Application Shortcut' from the Tools menu. When jsbeeb runs as an application, it *can* prevent ctrl-W from closing the window.";
6
+
7
+ /**
8
+ * The page around the emulator: the reset menu items and the filestore
9
+ * download, what focus and unload do to the machine, the drop guard, the
10
+ * dialogs a URL can ask for, and the version stamp.
11
+ */
12
+ export class PageActions {
13
+ constructor({ loop, processor, keyboard, audioHandler, rewindUI, modals, parsedQuery, version }) {
14
+ this.processor = processor;
15
+ this.rewindUI = rewindUI;
16
+ this.pageListeners = new AbortController();
17
+ const { signal } = this.pageListeners;
18
+
19
+ for (const el of document.querySelectorAll(".initially-hidden")) el.classList.remove("initially-hidden");
20
+ document.getElementById("paste-form").addEventListener("submit", (event) => event.preventDefault());
21
+
22
+ window.addEventListener(
23
+ "blur",
24
+ () => {
25
+ keyboard.clearKeys();
26
+ loop.setEmulationLead(audioHandler.setWindowFocused(false));
27
+ },
28
+ { signal },
29
+ );
30
+ window.addEventListener("focus", () => loop.setEmulationLead(audioHandler.setWindowFocused(true)), { signal });
31
+
32
+ // To lower the chance of data loss, only the drop zone in the menu bar accepts drops.
33
+ document.addEventListener(
34
+ "dragover",
35
+ (event) => {
36
+ event.preventDefault();
37
+ event.dataTransfer.dropEffect = "none";
38
+ },
39
+ { signal },
40
+ );
41
+ document.addEventListener("drop", (event) => event.preventDefault(), { signal });
42
+
43
+ window.addEventListener(
44
+ "beforeunload",
45
+ (event) => {
46
+ if (!loop.isRunning() || !processor.sysvia.hasAnyKeyDown()) return;
47
+ event.preventDefault();
48
+ event.returnValue = UnloadWarning;
49
+ },
50
+ { signal },
51
+ );
52
+
53
+ document.getElementById("hard-reset").addEventListener("click", (event) => {
54
+ event.preventDefault();
55
+ this.hardReset();
56
+ });
57
+ document.getElementById("soft-reset").addEventListener("click", (event) => {
58
+ event.preventDefault();
59
+ this.softReset();
60
+ });
61
+ document.getElementById("download-filestore-link").addEventListener("click", () => this.downloadFilestore());
62
+
63
+ if (Object.hasOwn(parsedQuery, "about")) modals.show("info");
64
+ if (Object.hasOwn(parsedQuery, "pp-tos")) modals.show("pp-tos");
65
+
66
+ const versionElement = document.getElementById("jsbeeb-version");
67
+ if (versionElement) versionElement.textContent = `Version ${version}`;
68
+ }
69
+
70
+ /** Stops listening to the window and document; the elements go with the page. */
71
+ dispose() {
72
+ this.pageListeners.abort();
73
+ }
74
+
75
+ hardReset() {
76
+ this.rewindUI.reset();
77
+ this.processor.reset(true);
78
+ }
79
+
80
+ softReset() {
81
+ this.processor.reset(false);
82
+ }
83
+
84
+ downloadFilestore() {
85
+ if (!this.processor.filestore) return;
86
+ downloadDriveData(this.processor.filestore.scsi, "scsi", ".dat");
87
+ }
88
+ }
@@ -1,44 +1,45 @@
1
1
  import { AudioOutputs } from "../audio-output.js";
2
2
 
3
3
  /**
4
- * The sound output, speaker amount and display mode controls on the top bar.
5
- * Choices go to the callbacks; what is shown follows the show methods, so the
6
- * bar can be kept in step with the same settings elsewhere.
4
+ * The sound output, speaker amount and display mode controls on the top bar:
5
+ * a view of those three settings, and a way to set them.
7
6
  */
8
7
  export class QuickSettings {
9
- constructor({ onAudioOutput, onSpeakerAmount, onDisplayMode }, { audioOutput, speakerAmount, displayMode }) {
8
+ constructor(settings) {
10
9
  this.output = document.getElementById("audio-output");
11
10
  this.amount = document.getElementById("speaker-amount");
12
11
  this.display = document.getElementById("display-mode");
13
12
  if (!this.output || !this.amount || !this.display) return;
14
13
 
15
- this.showAudioOutput(audioOutput);
16
- this.showSpeakerAmount(speakerAmount);
17
- this.showDisplayMode(displayMode);
14
+ this.showAudioOutput(settings.audioOutput);
15
+ this.showSpeakerAmount(settings.speakerAmount);
16
+ this.showDisplayMode(settings.displayMode);
17
+ settings.on("audioOutput", (audioOutput) => this.showAudioOutput(audioOutput));
18
+ settings.on("speakerAmount", (speakerAmount) => this.showSpeakerAmount(speakerAmount));
19
+ settings.on("displayMode", (displayMode) => this.showDisplayMode(displayMode));
18
20
 
19
21
  this.output.addEventListener("click", (e) => {
20
- const value = e.target.closest("[data-output]")?.dataset.output;
21
- if (value) onAudioOutput(value);
22
+ const audioOutput = e.target.closest("[data-output]")?.dataset.output;
23
+ if (audioOutput) settings.set({ audioOutput });
22
24
  });
23
- this.amount.addEventListener("input", () => onSpeakerAmount(parseFloat(this.amount.value)));
25
+ this.amount.addEventListener("input", () => settings.set({ speakerAmount: parseFloat(this.amount.value) }));
24
26
  this.display.addEventListener("click", (e) => {
25
- const mode = e.target.closest("[data-mode]")?.dataset.mode;
26
- if (mode) onDisplayMode(mode);
27
+ const displayMode = e.target.closest("[data-mode]")?.dataset.mode;
28
+ if (displayMode) settings.set({ displayMode });
27
29
  });
28
30
  }
29
31
 
30
32
  showAudioOutput(audioOutput) {
31
- if (!this.output) return;
32
33
  select(this.output.querySelectorAll("[data-output]"), (b) => b.dataset.output === audioOutput);
33
34
  this.amount.disabled = audioOutput !== AudioOutputs.speaker;
34
35
  }
35
36
 
36
37
  showSpeakerAmount(speakerAmount) {
37
- if (this.amount) this.amount.value = speakerAmount;
38
+ this.amount.value = speakerAmount;
38
39
  }
39
40
 
40
41
  showDisplayMode(mode) {
41
- if (this.display) select(this.display.querySelectorAll("[data-mode]"), (b) => b.dataset.mode === mode);
42
+ select(this.display.querySelectorAll("[data-mode]"), (b) => b.dataset.mode === mode);
42
43
  }
43
44
  }
44
45
 
@@ -1,4 +1,3 @@
1
- import * as utils from "../utils.js";
2
1
  import { toast } from "./toast.js";
3
2
 
4
3
  export const errorText = (error) => error?.message ?? `${error}`;
@@ -15,12 +14,6 @@ export function reportIgnoredFiles(name, ignored) {
15
14
  });
16
15
  }
17
16
 
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
17
  /** Handles a component's "notice" event by toasting it. */
25
18
  export function showNotice(event) {
26
19
  const { message, title, quietKey } = event.detail;
@@ -1,5 +1,3 @@
1
- "use strict";
2
-
3
1
  const ThumbnailWidth = 160;
4
2
  const ThumbnailHeight = 128;
5
3
  const FramebufferWidth = 1024;
@@ -1,6 +1,8 @@
1
- "use strict";
1
+ import { renderThumbnails, executeUntilFrame } from "./rewind-thumbnail.js";
2
+ import { RewindBuffer } from "../rewind.js";
3
+ import { RewindCaptureInterval } from "./emulation-loop.js";
2
4
 
3
- import { renderThumbnails, executeUntilFrame } from "../rewind-thumbnail.js";
5
+ const RewindBufferSnapshots = 30;
4
6
 
5
7
  /**
6
8
  * Rewind scrubber UI — a filmstrip overlay showing thumbnails of recent
@@ -9,18 +11,16 @@ import { renderThumbnails, executeUntilFrame } from "../rewind-thumbnail.js";
9
11
  export class RewindUI {
10
12
  /**
11
13
  * @param {object} options
12
- * @param {object} options.rewindBuffer - RewindBuffer instance
13
14
  * @param {object} options.processor - Cpu6502 instance
14
15
  * @param {object} options.video - Video instance
15
- * @param {number} options.captureInterval - rewind capture interval in frames
16
- * @param {object} options.loop - the emulation loop, for pausing and resuming
16
+ * @param {object} options.loop - the emulation loop: says when to capture, and is held while the panel is open
17
17
  */
18
- constructor({ rewindBuffer, processor, video, captureInterval, loop }) {
19
- this.rewindBuffer = rewindBuffer;
18
+ constructor({ processor, video, loop }) {
19
+ this.rewindBuffer = new RewindBuffer(RewindBufferSnapshots);
20
20
  this.processor = processor;
21
21
  this.video = video;
22
- this.captureInterval = captureInterval;
23
22
  this.loop = loop;
23
+ loop.addEventListener("rewind-capture", () => this.capture());
24
24
 
25
25
  this.panel = document.getElementById("rewind-panel");
26
26
  this.filmstrip = document.getElementById("rewind-filmstrip");
@@ -28,7 +28,7 @@ export class RewindUI {
28
28
  this.openBtn = document.getElementById("rewind-open");
29
29
 
30
30
  this.isOpen = false;
31
- this.wasRunning = false;
31
+ this.resumeLoop = null;
32
32
  this.selectedIndex = -1;
33
33
  this.snapshots = [];
34
34
  this.savedState = null;
@@ -41,6 +41,11 @@ export class RewindUI {
41
41
  });
42
42
  }
43
43
 
44
+ capture() {
45
+ this.rewindBuffer.push(this.processor.snapshotState());
46
+ this.updateButtonState();
47
+ }
48
+
44
49
  /** Forget everything captured, as on a hard reset. */
45
50
  reset() {
46
51
  this.close();
@@ -55,37 +60,32 @@ export class RewindUI {
55
60
  this.snapshots = this.rewindBuffer.getAll();
56
61
  if (this.snapshots.length === 0) return;
57
62
 
58
- this.wasRunning = this.loop.isRunning();
59
- if (this.wasRunning) this.loop.stop(false);
60
-
63
+ this.resumeLoop = this.loop.pause("the rewind panel");
61
64
  this.isOpen = true;
62
- this.savedState = this.processor.snapshotState();
63
-
64
65
  try {
66
+ this.savedState = this.processor.snapshotState();
65
67
  const thumbnails = renderThumbnails(
66
68
  this.processor,
67
69
  this.snapshots,
68
70
  this.video,
69
- this.captureInterval,
71
+ RewindCaptureInterval,
70
72
  this.savedState,
71
73
  );
72
74
  this._populateFilmstrip(thumbnails);
75
+ this.panel.hidden = false;
76
+ // Use capture phase so keys don't leak to the emulator's keyboard handler
77
+ document.addEventListener("keydown", this._onKeyDown, true);
78
+
79
+ // Select the newest snapshot ("now") and jump to it
80
+ this.selectedIndex = this.snapshots.length - 1;
81
+ this._restoreAndPaint(this.selectedIndex);
82
+ this._updateSelectionHighlight();
83
+ this.filmstrip.scrollLeft = this.filmstrip.scrollWidth;
73
84
  } catch (e) {
74
- this.processor.restoreState(this.savedState);
85
+ if (this.savedState) this.processor.restoreState(this.savedState);
75
86
  this._closePanel();
76
- if (this.wasRunning) this.loop.go();
77
87
  throw e;
78
88
  }
79
-
80
- this.panel.hidden = false;
81
- // Use capture phase so keys don't leak to the emulator's keyboard handler
82
- document.addEventListener("keydown", this._onKeyDown, true);
83
-
84
- // Select the newest snapshot ("now") and jump to it
85
- this.selectedIndex = this.snapshots.length - 1;
86
- this._restoreAndPaint(this.selectedIndex);
87
- this._updateSelectionHighlight();
88
- this.filmstrip.scrollLeft = this.filmstrip.scrollWidth;
89
89
  }
90
90
 
91
91
  /**
@@ -100,7 +100,6 @@ export class RewindUI {
100
100
  this.processor.restoreState(this.snapshots[this.selectedIndex]);
101
101
  }
102
102
  this._closePanel();
103
- if (this.wasRunning) this.loop.go();
104
103
  }
105
104
 
106
105
  /**
@@ -110,7 +109,6 @@ export class RewindUI {
110
109
  if (!this.isOpen) return;
111
110
  this._renderState(this.savedState);
112
111
  this._closePanel();
113
- if (this.wasRunning) this.loop.go();
114
112
  }
115
113
 
116
114
  /** Alias for cancel — closing the panel without explicit commit cancels. */
@@ -146,6 +144,8 @@ export class RewindUI {
146
144
  this.snapshots = [];
147
145
  this.savedState = null;
148
146
  document.removeEventListener("keydown", this._onKeyDown, true);
147
+ this.resumeLoop?.();
148
+ this.resumeLoop = null;
149
149
  }
150
150
 
151
151
  /**
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The play and pause buttons on the top bar, kept in step with the loop's
3
+ * running state. pause() and resume() are also the desktop app's menu actions.
4
+ */
5
+ export class RunControls {
6
+ /**
7
+ * @param {object} options
8
+ * @param {object} options.loop - the emulation loop
9
+ * @param {object} options.dbgr - the debugger, hidden on resume
10
+ * @param {object} options.keyboard - the machine keyboard, told the running state
11
+ */
12
+ constructor({ loop, dbgr, keyboard }) {
13
+ this.loop = loop;
14
+ this.dbgr = dbgr;
15
+ this.keyboard = keyboard;
16
+ this.playButton = document.getElementById("debug-play");
17
+ this.pauseButton = document.getElementById("debug-pause");
18
+
19
+ loop.addEventListener("running", () => {
20
+ const running = loop.isRunning();
21
+ keyboard.setRunning(running);
22
+ this.playButton.disabled = running;
23
+ this.pauseButton.disabled = !running;
24
+ });
25
+ this.pauseButton.addEventListener("click", () => this.pause());
26
+ this.playButton.addEventListener("click", () => this.resume());
27
+ }
28
+
29
+ /** Stop the loop into the debugger. */
30
+ pause() {
31
+ this.loop.stop(true);
32
+ }
33
+
34
+ /** Hide the debugger; the keyboard's resume event restarts the loop. */
35
+ resume() {
36
+ this.dbgr.hide();
37
+ this.keyboard.resumeEmulation();
38
+ }
39
+ }