jsbeeb 1.22.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.js CHANGED
@@ -1,224 +1,90 @@
1
- import * as bootstrap from "bootstrap";
2
1
  import { version } from "../package.json";
3
2
 
4
3
  import "bootswatch/dist/darkly/bootstrap.min.css";
5
4
  import "./jsbeeb.css";
6
5
 
7
6
  import * as utils from "./utils.js";
8
- import { FakeVideo, Video } from "./video.js";
9
7
  import { Debugger } from "./web/debug.js";
10
- import { Cpu6502, AtomCpu6502 } from "./6502.js";
11
8
  import * as utils_atom from "./utils_atom.js";
12
- import { LoadSD } from "./mmc.js";
13
- import { Cmos, localStoragePersistence } from "./cmos.js";
14
- import { StairwayToHell } from "./sth.js";
15
- import { BbcDiscArchive, Provenance, describe as describeHfe, matches, provenancesIn } from "./bbcdiscs.js";
16
9
  import { GamePad } from "./gamepads.js";
17
- import * as disc from "./fdc.js";
18
- import { loadTapeFromData } from "./tapes.js";
19
- import { GoogleDriveLoader } from "./google-drive.js";
20
- import * as tokeniser from "./basic-tokenise.js";
21
- import * as canvasLib from "./canvas.js";
22
- import { Config } from "./config.js";
23
- import { DefaultModel, findModel, tubeModelFor } from "./models.js";
24
10
  import { initialise as electron } from "./app/electron.js";
25
11
  import { AudioHandler } from "./web/audio-handler.js";
26
- import { DefaultAudioOutput, isAudioOutput } from "./audio-output.js";
27
12
  import { QuickSettings } from "./web/quick-settings.js";
28
- import { Econet } from "./econet.js";
29
- import { DiscLayout, toSsdOrDsd } from "./disc.js";
30
- import { toHfe } from "./disc-hfe.js";
31
- import { Keyboard } from "./keyboard.js";
32
- import { GamepadSource } from "./gamepad-source.js";
33
13
  import { toast } from "./web/toast.js";
34
- import { MicrophoneInput } from "./microphone-input.js";
35
- import { SpeechOutput } from "./speech-output.js";
14
+ import { BuiltInImages, MediaLoader } from "./web/media-loader.js";
15
+ import { AutobootTicks } from "./web/archive-list.js";
16
+ import { SthPicker } from "./web/sth-picker.js";
17
+ import { HfePicker } from "./web/hfe-picker.js";
18
+ import { GoogleDrivePicker } from "./web/google-drive-picker.js";
19
+ import { isSnapshotFile, SnapshotUI } from "./web/snapshot-ui.js";
20
+ import { Autoboot } from "./web/autoboot.js";
21
+ import { Display } from "./web/display.js";
22
+ import { Layout } from "./web/layout.js";
23
+ import { EmulationLoop, RewindCaptureInterval } from "./web/emulation-loop.js";
24
+ import { KeyboardSetup } from "./web/keyboard-setup.js";
25
+ import { AnalogueInputs } from "./web/analogue-inputs.js";
26
+ import { FrontPanel } from "./web/front-panel.js";
27
+ import { Machine } from "./web/machine.js";
28
+ import { Drives } from "./web/drives.js";
29
+ import { UrlState } from "./web/url-state.js";
30
+ import { Modals } from "./web/modals.js";
31
+ import { Settings } from "./web/settings.js";
36
32
  import { Printer } from "./printer.js";
37
- import { MouseJoystickSource } from "./mouse-joystick-source.js";
38
- import { calculateMouseCoordinates } from "./mouse-coordinates.js";
39
- import { getFilterForMode } from "./canvas.js";
40
- import {
41
- createSnapshot,
42
- restoreSnapshot,
43
- snapshotToJSON,
44
- snapshotFromJSON,
45
- isSameModel,
46
- hasCoProcessor,
47
- } from "./snapshot.js";
48
- import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
49
- import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
50
33
  import { RewindBuffer } from "./rewind.js";
51
- import { RewindUI } from "./rewind-ui.js";
52
- import { DiscVisualiser } from "./disc-visualiser.js";
53
- import { downloadBlob } from "./dom-utils.js";
54
- import {
55
- buildUrlFromParams,
56
- DriveTracks,
57
- guessModelFromHostname,
58
- ParamTypes,
59
- parseMediaParams,
60
- parseQueryString,
61
- processAutobootParams,
62
- processDriveTrackParams,
63
- processInputParams,
64
- } from "./url-params.js";
34
+ import { RewindUI } from "./web/rewind-ui.js";
35
+ import { DiscVisualiser } from "./web/disc-visualiser.js";
36
+ import { downloadDriveData } from "./dom-utils.js";
37
+ import { parseMediaParams, processAutobootParams, processDriveTrackParams, processInputParams } from "./url-params.js";
65
38
 
66
- let processor;
67
- let video;
68
- let rewindUI;
69
- const dbgr = new Debugger();
70
- let frames = 0;
71
- let frameSkip = 0;
72
- let syncLights;
73
- let discSth;
74
- let tapeSth;
75
- let hfeArchive;
76
- let running;
77
- let model;
78
-
79
- // Route tape to the correct interface (ACIA for BBC, PPIA for Atom)
80
- function setProcessorTape(tape) {
81
- if (model.isAtom) {
82
- processor.atomppia.setTape(tape);
83
- } else {
84
- processor.acia.setTape(tape);
85
- }
86
- }
87
-
88
- // Convert text to machine-appropriate key sequences (BBC or Atom)
89
- function stringToMachineKeys(text) {
90
- return model.isAtom ? utils_atom.stringToATOMKeys(text) : utils.stringToBBCKeys(text);
91
- }
92
-
93
- const gamepad = new GamePad();
94
- if (!window.isSecureContext)
95
- toast("Gamepads only work over https, so any joystick plugged in here is not seen.", {
96
- title: "Gamepads",
97
- quietKey: "quietInsecureGamepads",
98
- });
99
- const availableImages = [
100
- {
101
- name: "Elite",
102
- desc: "An 8-bit classic. Hit F10 to launch from the space station, then use <, >, S, X and A to fly around.",
103
- file: "elite.ssd",
104
- },
105
- {
106
- name: "Welcome",
107
- desc: "The disc supplied with BBC Disc systems to demonstrate some of the features of the system.",
108
- file: "Welcome.ssd",
109
- },
110
- {
111
- name: "Music 5000",
112
- desc: "The Music 5000 system disk and demo songs.",
113
- file: "5000mstr36008.ssd",
114
- },
115
- ];
116
- let discImage = availableImages[0].file;
117
- const extraRoms = [];
118
-
119
- // Build the query string from the URL
120
- const queryString = document.location.search.substring(1) + "&" + window.location.hash.substring(1);
121
- let secondDiscImage = null;
122
-
123
- // Define parameter types
124
- const paramTypes = {
125
- // Array parameters
126
- rom: ParamTypes.ARRAY,
127
-
128
- // Boolean parameters
129
- embed: ParamTypes.BOOL,
130
- fasttape: ParamTypes.BOOL,
131
- noseek: ParamTypes.BOOL,
132
- debug: ParamTypes.BOOL,
133
- verbose: ParamTypes.BOOL,
134
- autoboot: ParamTypes.BOOL,
135
- autochain: ParamTypes.BOOL,
136
- autorun: ParamTypes.BOOL,
137
- hasMusic5000: ParamTypes.BOOL,
138
- hasTeletextAdaptor: ParamTypes.BOOL,
139
- hasEconet: ParamTypes.BOOL,
140
- glEnabled: ParamTypes.BOOL,
141
- fakeVideo: ParamTypes.BOOL,
142
- logFdcCommands: ParamTypes.BOOL,
143
- logFdcStateChanges: ParamTypes.BOOL,
144
- coProcessor: ParamTypes.BOOL,
145
- mouseJoystickEnabled: ParamTypes.BOOL,
146
- speechOutput: ParamTypes.BOOL,
147
- audioDebug: ParamTypes.BOOL,
148
- audioOutput: ParamTypes.STRING,
39
+ // ------------------------------------------------------------------------
40
+ // What the URL asked for.
41
+ // ------------------------------------------------------------------------
149
42
 
150
- // Numeric parameters
151
- speed: ParamTypes.INT,
152
- stationId: ParamTypes.INT,
153
- frameSkip: ParamTypes.INT,
154
- audiofilterfreq: ParamTypes.FLOAT,
155
- audiofilterq: ParamTypes.FLOAT,
156
- speakerAmount: ParamTypes.FLOAT,
157
- audioLatencyMs: ParamTypes.FLOAT,
158
- cpuMultiplier: ParamTypes.FLOAT,
159
- tubeCpuMultiplier: ParamTypes.FLOAT,
160
- microphoneChannel: ParamTypes.INT,
161
-
162
- // String parameters (these are the default but listed for clarity)
163
- model: ParamTypes.STRING,
164
- disc: ParamTypes.STRING,
165
- disc1: ParamTypes.STRING,
166
- disc2: ParamTypes.STRING,
167
- tape: ParamTypes.STRING,
168
- mmc: ParamTypes.STRING,
169
- keyLayout: ParamTypes.STRING,
170
- autotype: ParamTypes.STRING,
171
- displayMode: ParamTypes.STRING,
172
- drive0Tracks: ParamTypes.STRING,
173
- drive1Tracks: ParamTypes.STRING,
174
- };
175
-
176
- // Parse the query string with parameter types
177
- let parsedQuery = parseQueryString(queryString, paramTypes);
43
+ const urlState = new UrlState(window.location, window.history);
44
+ const parsedQuery = urlState.params;
178
45
  let { needsAutoboot, autoType } = processAutobootParams(parsedQuery);
179
- let keyLayout = window.localStorage.keyLayout || "physical";
180
-
181
- const BBC = utils.BBC;
182
- const keyCodes = utils.keyCodes;
183
- const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
184
- let fastAsPossible = false;
185
- let fastTape = false;
186
- let noSeek;
187
- let stationId = 101;
188
- let econet = null;
189
46
 
190
- // Parse disc and tape images from query parameters
47
+ let discImage = BuiltInImages[0].file;
48
+ let secondDiscImage = null;
191
49
  const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
192
- const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
193
-
194
- // Only assign if values are provided
195
50
  if (queryDiscImage) discImage = queryDiscImage;
196
51
  if (querySecondDisc) secondDiscImage = querySecondDisc;
52
+ const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
197
53
 
198
- // Handle specific query parameters
54
+ const extraRoms = [];
199
55
  if (Array.isArray(parsedQuery.rom)) {
200
56
  parsedQuery.rom.forEach((romPath) => {
201
57
  if (romPath) extraRoms.push(romPath);
202
58
  });
203
59
  }
204
- if (parsedQuery.keyLayout) {
205
- keyLayout = (parsedQuery.keyLayout + "").toLowerCase();
206
- }
60
+
61
+ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
62
+ const noSeek = !!parsedQuery.noseek;
63
+ const stationId = parsedQuery.stationId !== undefined ? parsedQuery.stationId : 101;
64
+
65
+ const tryGl = parsedQuery.glEnabled ?? true;
66
+ const lowLatency = parsedQuery.lowLatency ?? true;
67
+
207
68
  if (parsedQuery.embed) {
208
69
  for (const el of document.querySelectorAll(".embed-hide")) el.style.display = "none";
209
70
  document.body.style.backgroundColor = "transparent";
210
71
  }
211
72
 
212
- fastTape = !!parsedQuery.fasttape;
213
- noSeek = !!parsedQuery.noseek;
73
+ // ------------------------------------------------------------------------
74
+ // The pieces that exist before the machine: settings, screen, sound.
75
+ // ------------------------------------------------------------------------
76
+
77
+ const dbgr = new Debugger();
214
78
 
215
- if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
216
- if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
79
+ const gamepad = new GamePad();
80
+ if (!window.isSecureContext)
81
+ toast("Gamepads only work over https, so any joystick plugged in here is not seen.", {
82
+ title: "Gamepads",
83
+ quietKey: "quietInsecureGamepads",
84
+ });
217
85
 
218
86
  const printer = new Printer({
219
- onOutput: (char) => {
220
- if (printerTextArea) printerTextArea.value += char;
221
- },
87
+ onOutput: (char) => frontPanel.printChar(char),
222
88
  onFirstOutput: () =>
223
89
  toast("Printer output is being kept. Press Ctrl-B to open the printer window.", {
224
90
  title: "Printer",
@@ -226,421 +92,58 @@ const printer = new Printer({
226
92
  }),
227
93
  });
228
94
 
229
- // Accessibility switch state — bits 0-7 correspond to switches 1-8.
230
- // Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
231
- let switchState = 0xff;
232
-
233
- const userPort = {
234
- write() {},
235
- read() {
236
- return switchState;
237
- },
238
- };
239
-
240
- // Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
241
- // Must be created before Config so the onClose callback and the initial checkbox state can reference it.
242
- const speechOutput = new SpeechOutput();
243
-
244
- function setSpeechOutput(enabled) {
245
- speechOutput.enabled = enabled;
246
- if (enabled && typeof speechSynthesis === "undefined")
247
- toast("This browser has no speech synthesis, so speech output has nothing to speak with.", {
248
- title: "Speech",
249
- });
250
- }
251
- setSpeechOutput(!!parsedQuery.speechOutput);
252
-
253
- const config = new Config(
254
- function onChange(changed) {
255
- if (changed.audioOutput) applyAudioOutput(changed.audioOutput);
256
- if (changed.speakerAmount !== undefined) applySpeakerAmount(changed.speakerAmount);
257
- if (changed.displayMode) applyDisplayMode(changed.displayMode);
258
- },
259
- function onClose(changed) {
260
- parsedQuery = Object.assign(parsedQuery, changed);
261
- if (changed.keyLayout) {
262
- window.localStorage.keyLayout = changed.keyLayout;
263
- emulationConfig.keyLayout = changed.keyLayout;
264
- keyboard.setKeyLayout(changed.keyLayout);
265
- }
266
- if (changed.mouseJoystickEnabled !== undefined || changed.microphoneChannel !== undefined) {
267
- updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
268
-
269
- if (changed.microphoneChannel !== undefined) {
270
- setupMicrophone();
271
- }
272
- }
273
- if (changed.speechOutput !== undefined) setSpeechOutput(!!changed.speechOutput);
274
- if (changed.tubeCpuMultiplier !== undefined) {
275
- emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
276
- config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
277
- if (processor.hasTube) {
278
- processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
279
- }
280
- }
281
- updateUrl();
282
- },
283
- function onRestartRequired() {
284
- areYouSure(
285
- "Your change is saved, but only takes effect when the emulator restarts. Restart now?",
286
- "Restart now",
287
- "Later",
288
- function () {
289
- window.location.reload();
290
- },
291
- );
95
+ const keys = new KeyboardSetup({
96
+ enterDebugger: () => loop.stop(true),
97
+ reload: () => window.location.reload(),
98
+ toggleFast: () => loop.toggleFastAsPossible(),
99
+ openRewind: () => rewindUI.open(),
100
+ openPrinter: () => frontPanel.checkPrinterWindow(),
101
+ pause: () => loop.stop(false),
102
+ resume: () => loop.go(),
103
+ onAnyKeyDown: () => {
104
+ audioHandler.tryResume();
105
+ inputs.ensureMicrophoneRunning();
292
106
  },
293
- );
294
-
295
- // Perform mapping of legacy models to the new format
296
- config.mapLegacyModels(parsedQuery);
297
-
298
- const requestedModelName = parsedQuery.model || guessModelFromHostname(window.location.hostname);
299
- const requestedModel = findModel(requestedModelName);
300
- if (!requestedModel)
301
- toast(`There is no model called "${requestedModelName}". Using ${DefaultModel.name} instead.`, {
302
- title: "Model",
303
- });
304
- config.setModel((requestedModel ?? DefaultModel).name);
305
- config.setKeyLayout(keyLayout);
306
- config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
307
- config.setMicrophoneChannel(parsedQuery.microphoneChannel);
308
- config.setCheckboxes({
309
- coProcessor: !!parsedQuery.coProcessor,
310
- hasEconet: !!parsedQuery.hasEconet,
311
- hasMusic5000: !!parsedQuery.hasMusic5000,
312
- hasTeletextAdaptor: !!parsedQuery.hasTeletextAdaptor,
313
- mouseJoystickEnabled: !!parsedQuery.mouseJoystickEnabled,
314
- speechOutput: speechOutput.enabled,
315
107
  });
316
- const displayMode = parsedQuery.displayMode || window.localStorage.displayMode || "rgb";
317
- config.setDisplayMode(displayMode);
318
- const audioOutput =
319
- [parsedQuery.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
320
- const speakerAmount =
321
- [parsedQuery.speakerAmount, parseFloat(window.localStorage.speakerAmount)].find(Number.isFinite) ?? 1;
322
-
323
- config.setAudioOutput(audioOutput);
324
- config.setSpeakerAmount(speakerAmount);
325
-
326
- function applyAudioOutput(output) {
327
- audioHandler.setAudioOutput(output);
328
- config.setAudioOutput(output);
329
- quickSettings?.showAudioOutput(output);
330
- window.localStorage.audioOutput = output;
331
- }
332
-
333
- function applySpeakerAmount(amount) {
334
- audioHandler.setSpeakerAmount(amount);
335
- config.setSpeakerAmount(amount);
336
- quickSettings?.showSpeakerAmount(amount);
337
- window.localStorage.speakerAmount = amount;
338
- }
339
-
340
- function applyDisplayMode(mode) {
341
- // swapCanvas settles displayModeFilter on whatever was really
342
- // built, so take the picture from that rather than the request.
343
- swapCanvas(getFilterForMode(mode));
344
- setCrtPic(displayModeFilter);
345
- // Trigger window resize to recalculate layout with new dimensions
346
- window.dispatchEvent(new Event("resize"));
347
- config.setDisplayMode(mode);
348
- quickSettings?.showDisplayMode(mode);
349
- window.localStorage.displayMode = mode;
350
- }
351
108
 
352
- model = config.model;
109
+ const settings = new Settings({ urlState });
110
+ const { config, speechOutput, keyLayout, displayMode, audioOutput, speakerAmount } = settings;
111
+ const model = settings.model;
353
112
 
354
113
  // Must come after we know the model, to validate names against those of the hardware.
355
114
  const keyMappingWarnings = processInputParams(
356
115
  parsedQuery,
357
- model.isAtom ? utils_atom.ATOM : BBC,
358
- keyCodes,
116
+ model.isAtom ? utils_atom.ATOM : utils.BBC,
117
+ utils.keyCodes,
359
118
  utils.userKeymap,
360
119
  gamepad,
361
120
  );
362
-
363
- // Depends on the config.setX calls above having applied the URL parameters.
364
- const emulationConfig = {
365
- keyLayout,
366
- cpuMultiplier,
367
- tubeCpuMultiplier: config.tubeCpuMultiplier,
368
- videoCyclesBatch: parsedQuery.videoCyclesBatch,
369
- tube: config.coProcessor ? tubeModelFor(config.model) : null,
370
- hasMusic5000: config.hasMusic5000,
371
- hasTeletextAdaptor: config.hasTeletextAdaptor,
372
- // ROM order determines sideways bank allocation, and the fittings' ROMs claim banks
373
- // before any the user asked for with ?rom=.
374
- extraRoms: [...config.extraRoms, ...extraRoms],
375
- userPort,
376
- printerPort: printer,
377
- getGamepads: function () {
378
- // Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
379
- return navigator.getGamepads ? navigator.getGamepads() : [];
380
- },
381
- debugFlags: {
382
- logFdcCommands: parsedQuery.logFdcCommands !== undefined,
383
- logFdcStateChanges: parsedQuery.logFdcStateChanges !== undefined,
384
- },
385
- };
386
-
387
- function sbBind(div, url, onload) {
388
- const img = div.querySelector("img");
389
- img.style.display = "none";
390
- if (!url) return;
391
- img.addEventListener("load", function () {
392
- onload(div, img);
393
- img.style.display = "";
394
- });
395
- img.src = url;
396
- }
397
-
398
- sbBind(document.querySelector(".sidebar.left"), parsedQuery.sbLeft, function (div, img) {
399
- div.style.left = -img.naturalWidth - 5 + "px";
400
- });
401
- sbBind(document.querySelector(".sidebar.right"), parsedQuery.sbRight, function (div, img) {
402
- div.style.right = -img.naturalWidth - 5 + "px";
403
- });
404
- sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function (div, img) {
405
- div.style.bottom = -img.naturalHeight + "px";
406
- });
407
-
408
- if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
409
- const cpuSpeed = model.cyclesPerSecond;
410
- const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
411
- const MaxCyclesPerTick = clocksPerSecond / 10;
412
-
413
- let tryGl = true;
414
- if (parsedQuery.glEnabled !== undefined) {
415
- tryGl = parsedQuery.glEnabled === "true";
416
- }
417
- let lowLatency = true;
418
- if (parsedQuery.lowLatency !== undefined) {
419
- lowLatency = parsedQuery.lowLatency === "true";
420
- }
421
- const screenCanvas = document.getElementById("screen");
422
-
423
- const errorDialog = document.getElementById("error-dialog");
424
- const errorDialogModal = new bootstrap.Modal(errorDialog);
425
-
426
- async function compressBlob(blob) {
427
- const stream = blob.stream().pipeThrough(new CompressionStream("gzip"));
428
- return new Response(stream).blob();
429
- }
430
-
431
- async function decompressBlob(blob) {
432
- const stream = blob.stream().pipeThrough(new DecompressionStream("gzip"));
433
- return new Response(stream).blob();
434
- }
435
-
436
- function showError(context, error) {
437
- errorDialog.querySelector(".context").textContent = context;
438
- errorDialog.querySelector(".error").textContent = error;
439
- errorDialogModal.show();
440
- }
441
-
442
- const errorText = (error) => error?.message ?? `${error}`;
443
-
444
- function reportLoadFailure(description, error) {
445
- console.error(`Error loading ${description}:`, error);
446
- toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
447
- }
448
-
449
- function reportIgnoredFiles(name, ignored) {
450
- if (!ignored.length) return;
451
- toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
452
- title: "Archive",
453
- });
454
- }
455
-
456
- async function unzipAndReport(data) {
457
- const unzipped = await utils.unzipDiscImage(data);
458
- reportIgnoredFiles(unzipped.name, unzipped.ignored);
459
- return unzipped;
460
- }
461
-
462
- function showNotice(event) {
463
- const { message, title, quietKey } = event.detail;
464
- toast(message, { title, quietKey });
465
- }
466
-
467
121
  if (keyMappingWarnings.length) {
468
122
  toast(`${keyMappingWarnings.join(" ")} The key names are listed in the README.`, {
469
123
  title: "Mappings in the URL",
470
124
  });
471
125
  }
472
-
473
126
  if (driveTrackWarnings.length) {
474
127
  toast(`${driveTrackWarnings.join(" ")} Auto is in use instead; pick 40 or 80 from the Discs menu.`, {
475
128
  title: "Disc drives",
476
129
  });
477
130
  }
478
131
 
479
- /** @returns {string} the DiscLayout to load an image for this drive with */
480
- function layoutForDrive(driveIndex) {
481
- return driveTracks[driveIndex] === DriveTracks.eighty ? DiscLayout.contiguous : DiscLayout.auto;
482
- }
483
-
484
- /** @returns {Number|undefined} the tracksPerStep the user fixed this drive at, if they fixed one */
485
- function tracksPerStepForDrive(driveIndex) {
486
- if (driveTracks[driveIndex] === DriveTracks.auto) return undefined;
487
- return driveTracks[driveIndex] === DriveTracks.forty ? 2 : 1;
488
- }
489
-
490
- function putDiscIn(driveIndex, loadedDisc) {
491
- const drive = processor.fdc.drives[driveIndex];
492
- const fixed = tracksPerStepForDrive(driveIndex);
493
- const was = drive.tracksPerStep;
494
- processor.fdc.loadDisc(driveIndex, loadedDisc, fixed);
495
- showDriveTracks(driveIndex);
496
- noteUnsavedWrites(loadedDisc);
497
- // A switch the user fixed does not move, so anything it does is not news.
498
- if (fixed === undefined && drive.tracksPerStep !== was) noteDriveTracks(driveIndex, loadedDisc.name);
499
- }
500
-
501
- let saidWritesAreNotKept = false;
502
-
503
- function noteUnsavedWrites(loadedDisc) {
504
- if (loadedDisc.savesChanges || saidWritesAreNotKept) return;
505
- loadedDisc.notifyOnFirstTrackWrite(() => {
506
- if (saidWritesAreNotKept) return;
507
- saidWritesAreNotKept = true;
508
- toast(`Changes to ${loadedDisc.name} are not saved. Use Discs, Download to keep a copy.`, {
509
- title: "Disc",
510
- quietKey: "quietDiscNotSaved",
511
- });
512
- });
513
- }
514
-
515
- const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
516
-
517
- function showDriveTracks(driveIndex) {
518
- const drive = processor.fdc?.drives[driveIndex];
519
- if (!drive) return;
520
- for (const button of driveTracksButtons(driveIndex))
521
- button.classList.toggle("active", tracksPerStepFor(button.dataset.tracks) === drive.tracksPerStep);
522
- }
523
-
524
- function driveTracksButtons(driveIndex) {
525
- return document.querySelectorAll(`.drive-tracks[data-drive="${driveIndex}"] [data-tracks]`);
526
- }
527
-
528
- function noteDriveTracks(driveIndex, discName) {
529
- const tracks = processor.fdc.drives[driveIndex].tracksPerStep === 2 ? "40" : "80";
530
- toast(`Drive ${driveIndex} switched to ${tracks} track for ${discName}.`, {
531
- title: "Disc drive",
532
- quietKey: "quietDriveTracks",
533
- });
534
- }
535
-
536
- // Test which filter is actually in use, not merely whether we got WebGL: a
537
- // filter can decline a context that works perfectly well for other modes, in
538
- // which case we are quietly left with an unfiltered display.
539
- function reportAnyFallback(displayCanvas, filterClass) {
540
- if (displayCanvas.filterClass === filterClass) return;
541
- const reason = displayCanvas.fallbackReason ? ` (${displayCanvas.fallbackReason})` : "";
542
- const { name } = filterClass.getDisplayConfig();
543
- toast(`${name} is not available on this device, so the standard display is in use${reason}.`, {
544
- title: "Display",
545
- quietKey: "quietDisplayFallback",
546
- });
547
- }
548
-
549
- function sizeCanvasFor(filterClass) {
550
- // Not `config`: that is the emulator's live configuration object, declared
551
- // at module scope and used throughout this file.
552
- const displayConfig = filterClass.getDisplayConfig();
553
- if (screenCanvas.width === displayConfig.canvasWidth && screenCanvas.height === displayConfig.canvasHeight) return;
554
- screenCanvas.width = displayConfig.canvasWidth;
555
- screenCanvas.height = displayConfig.canvasHeight;
556
- }
557
-
558
- function createCanvasForFilter(filterClass) {
559
- // Each mode says how many pixels it wants to draw into. Set this before
560
- // creating the context, which fixes its initial viewport.
561
- sizeCanvasFor(filterClass);
562
-
563
- const newCanvas = tryGl
564
- ? canvasLib.bestCanvas(screenCanvas, filterClass, lowLatency)
565
- : new canvasLib.Canvas(screenCanvas, lowLatency);
566
- reportAnyFallback(newCanvas, filterClass);
567
- return newCanvas;
568
- }
569
-
570
- let displayModeFilter = canvasLib.getFilterForMode(displayMode);
571
- function swapCanvas(newFilterClass) {
572
- // Everything but the filter is the same whatever the mode: the framebuffer
573
- // texture, the vertex buffers and fb32 all carry over untouched.
574
- canvasLib.useBestFilter(canvas, newFilterClass);
575
- reportAnyFallback(canvas, newFilterClass);
576
- // Follow the filter we ended up with, not the one we asked for: everything
577
- // downstream (the monitor picture, the canvas geometry, how large a drawing
578
- // buffer to ask for) comes from its display config.
579
- displayModeFilter = canvas.filterClass;
580
- // Back to the mode's own size, undoing any scaling the last one asked for.
581
- sizeCanvasFor(displayModeFilter);
582
- // Nothing else will redraw: the mode is changed from a modal, which stops
583
- // the emulator.
584
- video.paint();
585
- window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
586
- }
587
-
588
- const canvas = createCanvasForFilter(displayModeFilter);
589
- displayModeFilter = canvas.filterClass;
590
-
591
- // The emulator paints into its own framebuffer; flyback copies the finished
592
- // frame into the canvas and an animation frame presents it, so a stalled
593
- // display holds up the picture and not the emulation (issue #885).
594
- const videoFb32 = new Uint32Array(canvas.fb32.length);
595
- const pendingFrame = {
596
- minx: 0,
597
- miny: 0,
598
- maxx: 0,
599
- maxy: 0,
600
- lineBaseEven: 0,
601
- lineBaseOdd: 0,
602
- lineGrid: new Uint8Array(0),
603
- };
604
- let presentScheduled = false;
605
- let paintMsThisTick = 0;
606
- let presentMsMax = 0;
607
-
608
- function present() {
609
- presentScheduled = false;
610
- const start = performance.now();
611
- canvas.paint(pendingFrame.minx, pendingFrame.miny, pendingFrame.maxx, pendingFrame.maxy, pendingFrame);
612
- presentMsMax = Math.max(presentMsMax, performance.now() - start);
613
- }
132
+ if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
133
+ const cpuSpeed = model.cyclesPerSecond;
134
+ const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
614
135
 
615
- video = new Video(
616
- model.isMaster,
617
- videoFb32,
618
- function paint(minx, miny, maxx, maxy) {
619
- frames++;
620
- if (frames < frameSkip) return;
621
- frames = 0;
622
- const start = performance.now();
623
- canvas.fb32.set(videoFb32.subarray(miny * 1024, maxy * 1024), miny * 1024);
624
- if (pendingFrame.lineGrid.length !== this.lineGrid.length)
625
- pendingFrame.lineGrid = new Uint8Array(this.lineGrid.length);
626
- pendingFrame.lineGrid.set(this.lineGrid);
627
- Object.assign(pendingFrame, {
628
- minx,
629
- miny,
630
- maxx,
631
- maxy,
632
- lineBaseEven: this.lineBaseEven,
633
- lineBaseOdd: this.lineBaseOdd,
634
- });
635
- paintMsThisTick += performance.now() - start;
636
- if (!presentScheduled) {
637
- presentScheduled = true;
638
- window.requestAnimationFrame(present);
639
- }
640
- },
641
- { isAtom: model.isAtom },
642
- );
643
- if (parsedQuery.fakeVideo !== undefined) video = new FakeVideo();
136
+ const screenCanvas = document.getElementById("screen");
137
+ const display = new Display({
138
+ screenCanvas,
139
+ model,
140
+ mode: displayMode,
141
+ tryGl,
142
+ lowLatency,
143
+ fakeVideo: parsedQuery.fakeVideo !== undefined,
144
+ frameSkip: parsedQuery.frameSkip ?? 0,
145
+ });
146
+ const video = display.video;
644
147
 
645
148
  const audioStatsEl = document.getElementById("audio-stats");
646
149
  if (audioStatsEl) audioStatsEl.hidden = !parsedQuery.audioDebug;
@@ -662,185 +165,192 @@ const audioHandler = new AudioHandler({
662
165
  // start playing without user interaction, so we need to delay a
663
166
  // little to get a reliable indication.
664
167
  window.setTimeout(() => audioHandler.checkStatus(), 1000);
168
+
665
169
  const quickSettings = new QuickSettings(
666
- { onAudioOutput: applyAudioOutput, onSpeakerAmount: applySpeakerAmount, onDisplayMode: applyDisplayMode },
170
+ {
171
+ onAudioOutput: (output) => settings.applyAudioOutput(output),
172
+ onSpeakerAmount: (amount) => settings.applySpeakerAmount(amount),
173
+ onDisplayMode: (mode) => settings.applyDisplayMode(mode),
174
+ },
667
175
  { audioOutput, speakerAmount, displayMode },
668
176
  );
669
177
 
670
- for (const el of document.querySelectorAll(".initially-hidden")) el.classList.remove("initially-hidden");
178
+ // ------------------------------------------------------------------------
179
+ // The machine, the loop that runs it, and everything that feeds it.
180
+ // ------------------------------------------------------------------------
671
181
 
672
- const $discsModal = new bootstrap.Modal(document.getElementById("discs"));
673
- const $fsModal = new bootstrap.Modal(document.getElementById("econetfs"));
182
+ // Depends on the settings having applied the URL parameters.
183
+ const machine = new Machine({
184
+ model,
185
+ config,
186
+ parsedQuery,
187
+ keyLayout,
188
+ cpuMultiplier,
189
+ extraRoms,
190
+ stationId,
191
+ userPort: keys.userPort,
192
+ printer,
193
+ speechOutput,
194
+ video,
195
+ audioHandler,
196
+ dbgr,
197
+ });
198
+ const processor = machine.processor;
674
199
 
675
- /**
676
- * Helper function to read a file as binary string
677
- * @param {File} file - The file to read
678
- * @returns {Promise<string>} - Promise that resolves with the binary string content of the file, or rejects on error
679
- */
680
- function readFileAsBinaryString(file) {
681
- return new Promise((resolve, reject) => {
682
- const reader = new FileReader();
683
- reader.onload = (e) => {
684
- resolve(e.target.result);
685
- };
686
- reader.onerror = (e) => {
687
- console.error(`Error reading file ${file.name}:`, e);
688
- reject(new Error(`Failed to read file ${file.name}`));
689
- };
690
- reader.readAsBinaryString(file);
691
- });
692
- }
200
+ const rewindBuffer = new RewindBuffer(30);
201
+ const loop = new EmulationLoop({
202
+ processor,
203
+ display,
204
+ audioHandler,
205
+ dbgr,
206
+ gamepad,
207
+ keyboard: keys,
208
+ syncLights: () => frontPanel.syncLights(),
209
+ rewindBuffer,
210
+ onRewindCaptured: () => rewindUI.updateButtonState(),
211
+ clocksPerSecond,
212
+ cpuSpeed,
213
+ fastTape: !!parsedQuery.fasttape,
214
+ audioStatsNode,
215
+ });
693
216
 
694
- function replaceOrAddExtension(name, newExt) {
695
- const lastDot = name.lastIndexOf(".");
696
- if (lastDot === -1) {
697
- return name + newExt;
698
- }
699
- return name.substring(0, lastDot) + newExt;
700
- }
217
+ const debugPause = document.getElementById("debug-pause");
218
+ const debugPlay = document.getElementById("debug-play");
219
+ loop.addEventListener("running", () => {
220
+ const running = loop.isRunning();
221
+ keys.setRunning(running);
222
+ debugPlay.disabled = running;
223
+ debugPause.disabled = !running;
224
+ });
701
225
 
702
- /**
703
- * Helper function to download drive data in the specified format
704
- * @param {Uint8Array} data - The binary data to download
705
- * @param {string} name - The file name
706
- * @param {string} extension - The file extension to use
707
- */
708
- function downloadDriveData(data, name, extension) {
709
- const blob = new Blob([data], { type: "application/octet-stream" });
710
- downloadBlob(blob, replaceOrAddExtension(name, extension));
711
- }
226
+ const modals = new Modals({ loop });
712
227
 
713
- async function loadHTMLFile(file) {
714
- const imageData = utils.stringToUint8Array(await readFileAsBinaryString(file));
715
- const loadedDisc = disc.discFor(processor.fdc, file.name, imageData, undefined, layoutForDrive(0));
716
- // Local file: retain the image bytes for embedding in save-to-file snapshots.
717
- loadedDisc.setOriginalImage(imageData);
718
- putDiscIn(0, loadedDisc);
719
- delete parsedQuery.disc;
720
- delete parsedQuery.disc1;
721
- updateUrl();
722
- $discsModal.hide();
723
- }
228
+ const drives = new Drives({ fdc: processor.fdc, driveTracks, areYouSure: modals.areYouSure.bind(modals) });
229
+ const media = new MediaLoader({
230
+ processor,
231
+ model,
232
+ drives,
233
+ urlState,
234
+ config,
235
+ modals,
236
+ isSnapshotFile,
237
+ loadSnapshot: (file, buffer) => snapshots.loadStateFromFile(file, buffer),
238
+ // The archives are created further down; each source resolves when used.
239
+ sources: {
240
+ sth: (name) => sthPicker.discs.fetch(name),
241
+ tapeSth: (name) => sthPicker.tapes.fetch(name),
242
+ hfe: (path) => hfePicker.archive.fetch(path),
243
+ drive: (cat, layout) => drivePicker.load(cat, layout),
244
+ },
245
+ });
246
+ const autoBoot = new Autoboot({
247
+ model,
248
+ processor,
249
+ sendKeys: (keysToSend, check) => keys.sendRawKeyboard(keysToSend, check),
250
+ });
251
+ const autobootTicks = new AutobootTicks({ urlState });
252
+ const sthPicker = new SthPicker({
253
+ media,
254
+ drives,
255
+ modals,
256
+ urlState,
257
+ processor,
258
+ autoboot: (image) => autoBoot.boot(image),
259
+ });
260
+ const hfePicker = new HfePicker({
261
+ media,
262
+ drives,
263
+ modals,
264
+ urlState,
265
+ processor,
266
+ autoboot: (image) => autoBoot.boot(image),
267
+ });
268
+ const drivePicker = new GoogleDrivePicker({ media, drives, modals, processor });
269
+ const snapshots = new SnapshotUI({
270
+ processor,
271
+ model,
272
+ video,
273
+ media,
274
+ drives,
275
+ urlState,
276
+ modals,
277
+ loop,
278
+ });
724
279
 
725
- async function loadSCSIFile(file) {
726
- const binaryData = await readFileAsBinaryString(file);
727
- processor.filestore.scsi = utils.stringToUint8Array(binaryData);
280
+ const inputs = new AnalogueInputs({
281
+ processor,
282
+ screenCanvas,
283
+ getGamepads: machine.emulationConfig.getGamepads,
284
+ urlState,
285
+ config,
286
+ audioHandler,
287
+ });
288
+ if (parsedQuery.microphoneChannel !== undefined) {
289
+ // We need to use setTimeout to make sure this runs after the page has loaded
290
+ // This is needed because some browsers require user interaction for audio context
291
+ setTimeout(async () => {
292
+ await inputs.setupMicrophone();
293
+ }, 1000);
294
+ }
295
+ // Apply ADC source settings from URL parameters
296
+ inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
728
297
 
729
- processor.filestore.PC = 0x400;
730
- processor.filestore.SP = 0xff;
731
- processor.filestore.A = 1;
732
- processor.filestore.emulationSpeed = 0;
298
+ keys.attach({ processor, dbgr, keyLayout });
733
299
 
734
- // Reset any open receive blocks
735
- processor.econet.receiveBlocks = [];
736
- processor.econet.nextReceiveBlockNumber = 1;
300
+ const frontPanel = new FrontPanel({ processor, model, printer });
737
301
 
738
- $fsModal.hide();
739
- }
302
+ // ------------------------------------------------------------------------
303
+ // Rewind, the visualiser and the layout.
304
+ // ------------------------------------------------------------------------
740
305
 
741
- const pastetext = document.getElementById("paste-text");
742
- pastetext.closest("form").addEventListener("submit", (event) => event.preventDefault());
743
- pastetext.addEventListener("paste", function (event) {
744
- const text = event.clipboardData.getData("text/plain");
745
- sendRawKeyboard(stringToMachineKeys(text), true);
746
- });
747
- pastetext.addEventListener("dragover", function (event) {
748
- event.preventDefault();
749
- event.stopPropagation();
750
- event.dataTransfer.dropEffect = "copy";
751
- });
752
- pastetext.addEventListener("drop", async function (event) {
753
- utils.noteEvent("local", "drop");
754
- const file = event.dataTransfer.files[0];
755
- if (!file) return;
756
- try {
757
- const arrayBuffer = await file.arrayBuffer();
758
- if (isSnapshotFile(file.name, arrayBuffer)) {
759
- await loadStateFromFile(file, arrayBuffer);
760
- } else if (file.name.toLowerCase().endsWith(".uef")) {
761
- // Regular UEF tape image (not a BeebEm save state)
762
- setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
763
- toast(`Loaded ${file.name} as the tape.`, { title: "Dropped" });
764
- } else {
765
- await loadHTMLFile(file);
766
- toast(`Loaded ${file.name} into drive 0.`, { title: "Dropped" });
767
- }
768
- } catch (error) {
769
- reportLoadFailure(file.name, error);
770
- }
306
+ const rewindUI = new RewindUI({
307
+ rewindBuffer,
308
+ processor,
309
+ video,
310
+ captureInterval: RewindCaptureInterval,
311
+ loop,
771
312
  });
313
+ rewindUI.updateButtonState();
772
314
 
773
- const cubMonitor = document.getElementById("cub-monitor");
774
- function onCubMouseEvent(evt) {
775
- audioHandler.tryResume();
776
- if (document.activeElement !== document.body) document.activeElement.blur();
777
- const screenRect = screenCanvas.getBoundingClientRect();
778
- const { x, y } = calculateMouseCoordinates(evt, screenRect);
315
+ if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
316
+ else document.getElementById("disc-visualiser-open").classList.add("disabled");
779
317
 
780
- // Handle touchscreen
781
- if (processor.touchScreen) processor.touchScreen.onMouse(x, y, evt.buttons);
318
+ new Layout({
319
+ screenCanvas,
320
+ display,
321
+ embed: parsedQuery.embed !== undefined,
322
+ sidebars: { left: parsedQuery.sbLeft, right: parsedQuery.sbRight, bottom: parsedQuery.sbBottom },
323
+ });
782
324
 
783
- // Handle mouse joystick if enabled
784
- if (parsedQuery.mouseJoystickEnabled && mouseJoystickSource.isEnabled()) {
785
- // Use the API methods instead of direct manipulation
786
- mouseJoystickSource.onMouseMove(x, y);
325
+ // Everything a setting can reach now exists.
326
+ settings.wire({ audioHandler, display, quickSettings, machine, keys, inputs, modals });
787
327
 
788
- // Handle button events
789
- if (evt.type === "mousedown" && evt.button === 0) {
790
- mouseJoystickSource.onMouseDown(0);
791
- } else if (evt.type === "mouseup" && evt.button === 0) {
792
- mouseJoystickSource.onMouseUp(0);
793
- }
794
- }
328
+ // ------------------------------------------------------------------------
329
+ // The page's own handlers.
330
+ // ------------------------------------------------------------------------
795
331
 
796
- evt.preventDefault();
797
- }
798
- for (const eventType of ["mousemove", "mousedown", "mouseup"]) {
799
- cubMonitor.addEventListener(eventType, onCubMouseEvent);
800
- }
332
+ for (const el of document.querySelectorAll(".initially-hidden")) el.classList.remove("initially-hidden");
801
333
 
802
- function setCrtPic(filterMode) {
803
- const config = filterMode.getDisplayConfig();
804
- const monitorPic = document.getElementById("cub-monitor-pic");
805
- monitorPic.src = config.image;
806
- monitorPic.alt = config.imageAlt;
807
- monitorPic.width = config.imageWidth;
808
- monitorPic.height = config.imageHeight;
809
- }
810
- setCrtPic(displayModeFilter);
334
+ const pastetext = document.getElementById("paste-text");
335
+ pastetext.closest("form").addEventListener("submit", (event) => event.preventDefault());
336
+ pastetext.addEventListener("paste", function (event) {
337
+ const text = event.clipboardData.getData("text/plain");
338
+ keys.sendRawKeyboard(autoBoot.stringToMachineKeys(text), true);
339
+ });
811
340
 
812
341
  window.addEventListener("blur", function () {
813
- keyboard.clearKeys();
814
- setEmulationLead(audioHandler.setWindowFocused(false));
342
+ keys.clearKeys();
343
+ loop.setEmulationLead(audioHandler.setWindowFocused(false));
815
344
  });
816
- window.addEventListener("focus", () => setEmulationLead(audioHandler.setWindowFocused(true)));
817
-
818
- const fullscreenItem = document.getElementById("fs");
819
- if (document.fullscreenEnabled) {
820
- fullscreenItem.addEventListener("click", async (event) => {
821
- event.preventDefault();
822
- try {
823
- await screenCanvas.requestFullscreen();
824
- } catch (error) {
825
- toast(`Could not go fullscreen: ${errorText(error)}`, { title: "Fullscreen" });
826
- }
827
- });
828
- } else {
829
- fullscreenItem.closest("li").hidden = true;
830
- }
831
-
832
- let keyboard; // This will be initialised after the processor is created
833
-
834
- const debugPause = document.getElementById("debug-pause");
835
- const debugPlay = document.getElementById("debug-play");
345
+ window.addEventListener("focus", () => loop.setEmulationLead(audioHandler.setWindowFocused(true)));
836
346
 
837
347
  function pauseIntoDebugger() {
838
- stop(true);
348
+ loop.stop(true);
839
349
  }
840
350
 
841
351
  function resumeFromDebugger() {
842
352
  dbgr.hide();
843
- keyboard.resumeEmulation();
353
+ keys.resumeEmulation();
844
354
  }
845
355
 
846
356
  debugPause.addEventListener("click", pauseIntoDebugger);
@@ -857,7 +367,7 @@ document.addEventListener("drop", function (event) {
857
367
  });
858
368
 
859
369
  window.addEventListener("beforeunload", function (event) {
860
- if (running && processor.sysvia.hasAnyKeyDown()) {
370
+ if (loop.isRunning() && processor.sysvia.hasAnyKeyDown()) {
861
371
  const message =
862
372
  "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" +
863
373
  "As a workarond, create an 'Application Shortcut' from the Tools menu. When jsbeeb runs as an application, it *can* prevent ctrl-W from closing the window.";
@@ -867,1392 +377,67 @@ window.addEventListener("beforeunload", function (event) {
867
377
  }
868
378
  });
869
379
 
870
- if (config.hasEconet) {
871
- econet = new Econet(stationId, model.cyclesPerSecond);
872
- } else {
873
- document.getElementById("fsmenuitem").style.display = "none";
380
+ function hardReset() {
381
+ rewindUI.reset();
382
+ processor.reset(true);
874
383
  }
875
384
 
876
- const cmos = new Cmos(
877
- localStoragePersistence(
878
- () => window.localStorage,
879
- (error) =>
880
- toast(
881
- `Settings changed with *CONFIGURE will not be kept (${errorText(error)}). Check that this site is allowed to store data, and that its storage is not full.`,
882
- { title: "Settings", quietKey: "quietCmosSave" },
883
- ),
884
- ),
885
- model.cmosOverride,
886
- econet,
887
- );
888
-
889
- let printerWindow = null;
890
- let printerTextArea = null;
891
-
892
- function checkPrinterWindow() {
893
- if (printerWindow && !printerWindow.closed) return;
894
-
895
- printerWindow = window.open("", "_blank", "height=300,width=400");
896
- if (!printerWindow) {
897
- toast("The printer output window was blocked. Allow pop-up windows for this site, then press Ctrl-B again.", {
898
- title: "Printer",
899
- });
900
- return;
901
- }
902
- printerWindow.document.write(
903
- '<textarea id="text" rows="15" cols="40" placeholder="Printer outputs here..."></textarea>',
904
- );
905
- printerTextArea = printerWindow.document.getElementById("text");
906
- printerTextArea.value = printer.text;
907
- }
908
-
909
- const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
910
- processor = new CpuClass(model, {
911
- dbgr,
912
- video,
913
- soundChip: audioHandler.soundChip,
914
- ddNoise: audioHandler.ddNoise,
915
- relayNoise: audioHandler.relayNoise,
916
- music5000: config.hasMusic5000 ? audioHandler.music5000 : null,
917
- cmos,
918
- config: emulationConfig,
919
- econet,
920
- });
921
-
922
- printer.attach(processor.uservia);
923
-
924
- processor.teletextAdaptor?.addEventListener("notice", showNotice);
925
- processor.acia.addEventListener("notice", showNotice);
926
-
927
- // Create input sources
928
- const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
929
- // Create MicrophoneInput but don't enable by default
930
- const microphoneInput = new MicrophoneInput();
931
- microphoneInput.setErrorCallback((message) => {
932
- toast(`${message} The microphone channel has been turned off.`, { title: "Microphone" });
933
- });
934
-
935
- // Create MouseJoystickSource but don't enable by default
936
- const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
937
-
938
- /**
939
- * Attach an RS-423 composite handler to the ACIA that combines the touchscreen
940
- * (which sends position data to the BBC) with the speech output (which speaks
941
- * text the BBC sends out).
942
- */
943
- function setupRs423Handler() {
944
- processor.acia.setRs423Handler({
945
- onTransmit(val) {
946
- processor.touchScreen.onTransmit(val);
947
- speechOutput.onTransmit(val);
948
- },
949
- tryReceive(rts) {
950
- return processor.touchScreen.tryReceive(rts);
951
- },
952
- });
953
- }
954
-
955
- // Helper to manage ADC source configuration
956
- function updateAdcSources(mouseJoystickEnabled, microphoneChannel) {
957
- // Default all channels to the gamepad source.
958
- for (let ch = 0; ch < 4; ch++) {
959
- processor.adconverter.setChannelSource(ch, gamepadSource);
960
- }
961
-
962
- // Apply mouse joystick if enabled (takes priority on channels 0 & 1)
963
- if (mouseJoystickEnabled) {
964
- processor.adconverter.setChannelSource(0, mouseJoystickSource);
965
- processor.adconverter.setChannelSource(1, mouseJoystickSource);
966
- mouseJoystickSource.setVia(processor.sysvia);
967
- } else {
968
- mouseJoystickSource.setVia(null);
969
- }
970
-
971
- // Apply microphone if configured (can override any channel)
972
- if (microphoneChannel !== undefined) {
973
- processor.adconverter.setChannelSource(microphoneChannel, microphoneInput);
974
- }
975
- }
976
-
977
- async function ensureMicrophoneRunning() {
978
- if (microphoneInput.audioContext && microphoneInput.audioContext.state !== "running") {
979
- try {
980
- await microphoneInput.audioContext.resume();
981
- console.log("Microphone: Audio context resumed, new state:", microphoneInput.audioContext.state);
982
- } catch (err) {
983
- console.error("Microphone: Error resuming audio context:", err);
984
- return false;
985
- }
986
- }
987
- return true;
988
- }
989
-
990
- async function setupMicrophone() {
991
- const micPermissionStatus = document.getElementById("micPermissionStatus");
992
- micPermissionStatus.textContent = "Requesting microphone access...";
993
-
994
- // Try to initialise the microphone
995
- const success = await microphoneInput.initialise();
996
- if (success) {
997
- // Note: Channel assignment is handled by updateAdcSources()
998
- micPermissionStatus.textContent = "Microphone connected successfully";
999
- await ensureMicrophoneRunning();
1000
-
1001
- // Try starting audio context from user gesture
1002
- const tryAgain = async () => {
1003
- if (await ensureMicrophoneRunning()) document.removeEventListener("click", tryAgain);
1004
- };
1005
- document.addEventListener("click", tryAgain);
1006
- } else {
1007
- micPermissionStatus.textContent = `Error: ${microphoneInput.getErrorMessage() || "Unknown error"}`;
1008
- config.setMicrophoneChannel(undefined);
1009
- // Update URL to remove the parameter
1010
- delete parsedQuery.microphoneChannel;
1011
- updateUrl();
1012
- }
1013
- }
1014
-
1015
- if (parsedQuery.microphoneChannel !== undefined) {
1016
- // We need to use setTimeout to make sure this runs after the page has loaded
1017
- // This is needed because some browsers require user interaction for audio context
1018
- setTimeout(async () => {
1019
- await setupMicrophone();
1020
- }, 1000);
1021
- }
1022
-
1023
- // Apply ADC source settings from URL parameters
1024
- updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
1025
-
1026
- // Initialise keyboard now that processor exists
1027
- keyboard = new Keyboard({
1028
- processor,
1029
- inputEnabledFunction: () => document.activeElement && document.activeElement.id === "paste-text",
1030
- keyLayout,
1031
- dbgr,
1032
- });
1033
- keyboard.addEventListener("notice", showNotice);
1034
- keyboard.addEventListener("pause", () => stop(false));
1035
- keyboard.addEventListener("resume", () => go());
1036
- keyboard.addEventListener("break", (e) => {
1037
- // F12/Break: Reset processor
1038
- if (e.detail) utils.noteEvent("keyboard", "press", "break");
1039
- });
1040
-
1041
- // Register default key handlers
1042
- keyboard.registerKeyHandler(
1043
- utils.keyCodes.S,
1044
- (down) => {
1045
- if (down) {
1046
- utils.noteEvent("keyboard", "press", "S");
1047
- stop(true);
1048
- }
1049
- },
1050
- { alt: true, ctrl: false },
1051
- );
1052
-
1053
- keyboard.registerKeyHandler(
1054
- utils.keyCodes.R,
1055
- (down) => {
1056
- if (down) window.location.reload();
1057
- },
1058
- { alt: true, ctrl: false },
1059
- );
1060
-
1061
- // Register Ctrl key handlers
1062
- keyboard.registerKeyHandler(
1063
- utils.keyCodes.HOME,
1064
- (down) => {
1065
- if (down) {
1066
- utils.noteEvent("keyboard", "press", "home");
1067
- stop(true);
1068
- }
1069
- },
1070
- { alt: false, ctrl: true },
1071
- );
1072
-
1073
- keyboard.registerKeyHandler(
1074
- utils.keyCodes.INSERT,
1075
- (down) => {
1076
- if (down) {
1077
- utils.noteEvent("keyboard", "press", "insert");
1078
- fastAsPossible = !fastAsPossible;
1079
- }
1080
- },
1081
- { alt: false, ctrl: true },
1082
- );
1083
-
1084
- keyboard.registerKeyHandler(
1085
- utils.keyCodes.END,
1086
- (down) => {
1087
- if (down) {
1088
- utils.noteEvent("keyboard", "press", "end");
1089
- keyboard.pauseEmulation();
1090
- }
1091
- },
1092
- { alt: false, ctrl: true },
1093
- );
1094
-
1095
- keyboard.registerKeyHandler(
1096
- utils.keyCodes.PAGEDOWN,
1097
- (down) => {
1098
- if (down) {
1099
- utils.noteEvent("keyboard", "press", "pagedown");
1100
- if (rewindUI) rewindUI.open();
1101
- }
1102
- },
1103
- { alt: true, ctrl: false },
1104
- );
1105
-
1106
- keyboard.registerKeyHandler(
1107
- utils.keyCodes.B,
1108
- (down) => {
1109
- if (down) {
1110
- checkPrinterWindow();
1111
- }
1112
- },
1113
- { alt: false, ctrl: true },
1114
- );
1115
-
1116
- // Register accessibility switch key handlers.
1117
- // Keys 1–8 (K1–K8) and function keys F1–F8 both map to user port bits 0–7
1118
- // (active low: pressing the key clears the corresponding bit in &FE60).
1119
- //
1120
- // On real hardware, the Brilliant Computing switch interface box and special-ed
1121
- // joystick connect to the User Port only — they do not touch the analogue port
1122
- // or the System VIA fire buttons (PB4/PB5), which belong to the standard
1123
- // analogue joystick connector. So we only update switchState here.
1124
- {
1125
- const handleSwitch = (bit) => (down) => {
1126
- if (down) switchState &= ~(1 << bit);
1127
- else switchState |= 1 << bit;
1128
- };
1129
-
1130
- // Alt+1–8 and Alt+F1–F8 trigger the switches. Using Alt means the underlying
1131
- // key is never forwarded to the BBC Micro (keyboard.js bails out early when a
1132
- // handler fires), so typing numbers or using function keys works normally.
1133
- const altMod = { alt: true, ctrl: false };
1134
- for (let i = 0; i < 8; i++) {
1135
- keyboard.registerKeyHandler(utils.keyCodes.K1 + i, handleSwitch(i), altMod);
1136
- keyboard.registerKeyHandler(utils.keyCodes.F1 + i, handleSwitch(i), altMod);
1137
- }
1138
- }
1139
-
1140
- // Setup key handlers
1141
- document.addEventListener("keydown", (evt) => {
1142
- audioHandler.tryResume();
1143
- ensureMicrophoneRunning();
1144
- keyboard.keyDown(evt);
1145
- });
1146
- document.addEventListener("keypress", (evt) => keyboard.keyPress(evt));
1147
- document.addEventListener("keyup", (evt) => keyboard.keyUp(evt));
1148
-
1149
- function setDisc1Image(name) {
1150
- delete parsedQuery.disc;
1151
- parsedQuery.disc1 = name;
1152
- updateUrl();
1153
- config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
1154
- }
1155
-
1156
- function setDisc2Image(name) {
1157
- parsedQuery.disc2 = name;
1158
- updateUrl();
1159
- config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
1160
- }
1161
-
1162
- function setTapeImage(name) {
1163
- parsedQuery.tape = name;
1164
- updateUrl();
1165
- config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
1166
- }
1167
-
1168
- function clearArchiveList(listId) {
1169
- for (const el of document.querySelectorAll(`#${listId} li:not(.template)`)) el.remove();
1170
- }
1171
-
1172
- function showArchiveMessage(modalId, listId, message) {
1173
- const loading = document.querySelector(`#${modalId} .loading`);
1174
- loading.textContent = message;
1175
- loading.style.display = "";
1176
- clearArchiveList(listId);
1177
- }
1178
-
1179
- function filterArchiveList(listId, filter) {
1180
- filter = filter.toLowerCase();
1181
- for (const el of document.querySelectorAll(`#${listId} li:not(.template)`)) {
1182
- el.style.display = el.textContent.toLowerCase().includes(filter) ? "" : "none";
1183
- }
1184
- }
1185
-
1186
- function sthStartLoad() {
1187
- showArchiveMessage("sth", "sth-list", "Loading catalog from STH archive");
1188
- }
1189
-
1190
- async function discSthClick(item) {
1191
- utils.noteEvent("sth", "click", item);
1192
- setDisc1Image("sth:" + item);
1193
- const needsAutoboot = parsedQuery.autoboot !== undefined;
1194
- if (needsAutoboot) {
1195
- processor.reset(true);
1196
- }
1197
-
1198
- popupLoading("Loading " + item);
1199
- try {
1200
- const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
1201
- putDiscIn(0, loaded);
1202
- loadingFinished();
1203
-
1204
- if (needsAutoboot) {
1205
- autoboot(item);
1206
- }
1207
- } catch (err) {
1208
- console.error("Error loading disc image:", err);
1209
- loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
1210
- }
1211
- }
1212
-
1213
- async function tapeSthClick(item) {
1214
- utils.noteEvent("sth", "clickTape", item);
1215
- setTapeImage("sth:" + item);
1216
-
1217
- popupLoading("Loading " + item);
1218
- try {
1219
- const tape = await loadTapeImage(parsedQuery.tape);
1220
- setProcessorTape(tape);
1221
- loadingFinished();
1222
- } catch (err) {
1223
- console.error("Error loading tape image:", err);
1224
- loadingFinished(`Unable to load ${item} from the STH archive: ${errorText(err)}`);
1225
- }
1226
- }
1227
-
1228
- const $sthModal = new bootstrap.Modal(document.getElementById("sth"));
1229
- document.getElementById("sth").addEventListener("shown.bs.modal", () => {
1230
- document.getElementById("sth-filter").focus();
1231
- });
1232
-
1233
- function makeOnCat(onClick) {
1234
- return function (cat) {
1235
- clearArchiveList("sth-list");
1236
- const sthList = document.getElementById("sth-list");
1237
- document.querySelector("#sth .loading").style.display = "none";
1238
- const template = sthList.querySelector(".template");
1239
-
1240
- function doSome(all) {
1241
- const MaxAtATime = 100;
1242
- const Delay = 30;
1243
- const batch = all.slice(0, MaxAtATime);
1244
- const remaining = all.slice(MaxAtATime);
1245
- const filter = document.getElementById("sth-filter").value;
1246
- for (const name of batch) {
1247
- const row = template.cloneNode(true);
1248
- row.classList.remove("template");
1249
- sthList.appendChild(row);
1250
- row.querySelector(".name").textContent = name;
1251
- row.addEventListener("click", function () {
1252
- onClick(name);
1253
- $sthModal.hide();
1254
- });
1255
- row.style.display = name.toLowerCase().indexOf(filter) >= 0 ? "" : "none";
1256
- }
1257
- if (all.length) setTimeout(() => doSome(remaining), Delay);
1258
- }
1259
-
1260
- doSome(cat);
1261
- };
1262
- }
1263
-
1264
- function sthOnError() {
1265
- showArchiveMessage("sth", "sth-list", "There was an error accessing the STH archive");
1266
- }
1267
-
1268
- discSth = new StairwayToHell(sthStartLoad, makeOnCat(discSthClick), sthOnError, false);
1269
- tapeSth = new StairwayToHell(sthStartLoad, makeOnCat(tapeSthClick), sthOnError, true);
1270
- hfeArchive = new BbcDiscArchive(hfeStartLoad, hfeOnCat, hfeOnError);
1271
-
1272
- // Every archive picker offers the same autoboot choice, and it is one setting,
1273
- // so ticking it in either has to show in both.
1274
- const autobootChecks = document.querySelectorAll("#sth .autoboot, #hfe .autoboot");
1275
- function showAutoboot(checked) {
1276
- for (const check of autobootChecks) check.checked = checked;
1277
- }
1278
- for (const check of autobootChecks) {
1279
- check.addEventListener("click", function () {
1280
- showAutoboot(check.checked);
1281
- if (check.checked) {
1282
- parsedQuery.autoboot = "";
1283
- } else {
1284
- delete parsedQuery.autoboot;
1285
- }
1286
- updateUrl();
1287
- });
1288
- }
1289
-
1290
- document.addEventListener("click", function (e) {
1291
- const target = e.target.closest("a.sth");
1292
- if (!target) return;
1293
- const type = target.dataset.id;
1294
- if (type === "discs") {
1295
- discSth.populate();
1296
- } else if (type === "tapes") {
1297
- tapeSth.populate();
1298
- } else {
1299
- console.log("unknown id", type);
1300
- }
1301
- });
1302
-
1303
- function setSthFilter(filter) {
1304
- filterArchiveList("sth-list", filter);
1305
- }
1306
-
1307
- const sthFilter = document.getElementById("sth-filter");
1308
- sthFilter.addEventListener("change", () => setSthFilter(sthFilter.value));
1309
- sthFilter.addEventListener("keyup", () => setSthFilter(sthFilter.value));
1310
-
1311
- // Rendering is spread over several turns of the event loop, so a list that has
1312
- // been emptied may still have a chain of appends heading for it. Anything that
1313
- // clears the list takes a new ticket; a chain whose ticket is stale gives up.
1314
- let hfeRender = 0;
1315
-
1316
- function hfeStartLoad() {
1317
- hfeRender++;
1318
- showArchiveMessage("hfe", "hfe-list", "Loading catalogue from HFE archive");
1319
- }
1320
-
1321
- function hfeOnError() {
1322
- hfeRender++;
1323
- showArchiveMessage("hfe", "hfe-list", "There was an error accessing the HFE archive");
1324
- }
1325
-
1326
- async function hfeClick(file) {
1327
- utils.noteEvent("hfe", "click", file.path);
1328
- setDisc1Image("hfe:" + file.path);
1329
- const needsAutoboot = parsedQuery.autoboot !== undefined;
1330
- if (needsAutoboot) processor.reset(true);
1331
-
1332
- const name = describeHfe(file).title;
1333
- popupLoading("Loading " + name);
1334
- try {
1335
- const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
1336
- putDiscIn(0, loaded);
1337
- loadingFinished();
1338
- if (needsAutoboot) autoboot(name);
1339
- } catch (err) {
1340
- console.error("Error loading disc image:", err);
1341
- loadingFinished(`Unable to load ${name} from the HFE archive: ${errorText(err)}`);
1342
- }
1343
- }
1344
-
1345
- function hfeOnCat(catalogue) {
1346
- const ticket = ++hfeRender;
1347
- clearArchiveList("hfe-list");
1348
- const list = document.getElementById("hfe-list");
1349
- document.querySelector("#hfe .loading").style.display = "none";
1350
- const template = list.querySelector(".template");
1351
- showProvenanceChoices(catalogue, onHfeFilter);
1352
-
1353
- const addSome = (remaining) => {
1354
- if (ticket !== hfeRender) return;
1355
- const MaxAtATime = 100;
1356
- const Delay = 30;
1357
- // Read per batch: both can be changed while this is still going.
1358
- const filter = document.getElementById("hfe-filter").value.toLowerCase();
1359
- const shown = shownProvenances();
1360
- for (const file of remaining.slice(0, MaxAtATime)) {
1361
- const { title, publisher, detail } = describeHfe(file);
1362
- const row = template.cloneNode(true);
1363
- row.classList.remove("template");
1364
- row.querySelector(".name").textContent = title;
1365
- row.querySelector(".publisher").textContent = publisher;
1366
- row.querySelector(".detail").textContent = detail;
1367
- row.querySelector(".provenance").textContent =
1368
- file.provenance === Provenance.Reconstructed ? "reconstructed" : "";
1369
- if (file.notes) row.title = file.notes;
1370
- // The row is an anchor, and letting it navigate to "#" would push a
1371
- // history entry of its own on top of the one updateUrl pushes.
1372
- row.addEventListener("click", (event) => {
1373
- event.preventDefault();
1374
- hfeClick(file);
1375
- $hfeModal.hide();
1376
- });
1377
- row.hfeFile = file;
1378
- list.appendChild(row);
1379
- showHfeRow(row, file, filter, shown);
1380
- }
1381
- if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
1382
- };
1383
- addSome(catalogue);
1384
- }
1385
-
1386
- const $hfeModal = new bootstrap.Modal(document.getElementById("hfe"));
1387
- document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
1388
- document.getElementById("hfe-filter").focus();
1389
- });
1390
- document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
1391
-
1392
- const hfeFilter = document.getElementById("hfe-filter");
1393
- const hfeProvenance = document.getElementById("hfe-provenance");
1394
-
1395
- const HfeProvenanceLabels = {
1396
- [Provenance.Captured]: ["Captured", "Direct from disc"],
1397
- [Provenance.Reconstructed]: ["Reconstructed", "Rebuilt from a sector dump"],
1398
- };
1399
-
1400
- /** Which provenances the picker is showing, or null when it is not offering the choice. */
1401
- const shownProvenances = () => {
1402
- const boxes = [...hfeProvenance.querySelectorAll("input")];
1403
- return boxes.length ? new Set(boxes.filter((box) => box.checked).map((box) => box.value)) : null;
1404
- };
1405
-
1406
- // Offer one tick per provenance the archive actually holds, rather than naming
1407
- // them here: a source added later should appear without this having to change.
1408
- function showProvenanceChoices(catalogue, onChange) {
1409
- const present = provenancesIn(catalogue);
1410
- // Nothing to choose between: no ticks, and shownProvenances says "all".
1411
- if (present.length < 2) {
1412
- hfeProvenance.replaceChildren();
1413
- return;
1414
- }
1415
- const wasShown = shownProvenances();
1416
- hfeProvenance.replaceChildren(
1417
- ...present.map((provenance) => {
1418
- const [text, why] = HfeProvenanceLabels[provenance] ?? [provenance, ""];
1419
- const label = document.createElement("label");
1420
- label.title = why;
1421
- const box = document.createElement("input");
1422
- box.type = "checkbox";
1423
- box.value = provenance;
1424
- box.checked = !wasShown || wasShown.has(provenance);
1425
- box.addEventListener("change", onChange);
1426
- label.append(box, text);
1427
- return label;
1428
- }),
1429
- );
1430
- }
1431
-
1432
- const showHfeRow = (row, file, filter, shown) => (row.style.display = matches(file, filter, shown) ? "" : "none");
1433
-
1434
- const onHfeFilter = () => {
1435
- const filter = hfeFilter.value.toLowerCase();
1436
- const shown = shownProvenances();
1437
- for (const row of document.querySelectorAll("#hfe-list li:not(.template)"))
1438
- showHfeRow(row, row.hfeFile, filter, shown);
1439
- };
1440
- hfeFilter.addEventListener("change", onHfeFilter);
1441
- hfeFilter.addEventListener("keyup", onHfeFilter);
1442
-
1443
- function sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
1444
- if (keyboard) {
1445
- keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
1446
- } else {
1447
- console.warn("Tried to send keys before keyboard was initialised");
1448
- }
1449
- }
1450
-
1451
- function autoboot(image) {
1452
- const BBC = utils.BBC;
1453
-
1454
- console.log("Autobooting disc");
1455
- utils.noteEvent("init", "autoboot", image);
1456
-
1457
- // Shift-break simulation, hold SHIFT for 1000ms.
1458
- sendRawKeyboard([BBC.SHIFT, 1000], false);
1459
- }
1460
-
1461
- function autoBootType(keys) {
1462
- console.log("Auto typing '" + keys + "'");
1463
- utils.noteEvent("init", "autochain");
1464
-
1465
- const bbcKeys = stringToMachineKeys(keys);
1466
- sendRawKeyboard([1000].concat(bbcKeys), false);
1467
- }
1468
-
1469
- function autoChainTape() {
1470
- console.log("Auto Chaining Tape");
1471
- utils.noteEvent("init", "autochain");
1472
-
1473
- const bbcKeys = stringToMachineKeys('*TAPE\nCH.""\n');
1474
- sendRawKeyboard([1000].concat(bbcKeys), false);
1475
- }
1476
-
1477
- function autoRunTape() {
1478
- console.log("Auto Running Tape");
1479
- utils.noteEvent("init", "autorun");
1480
-
1481
- const bbcKeys = stringToMachineKeys("*TAPE\n*/\n");
1482
- sendRawKeyboard([1000].concat(bbcKeys), false);
1483
- }
1484
-
1485
- function autoRunBasic() {
1486
- console.log("Auto Running basic");
1487
- utils.noteEvent("init", "autorunbasic");
1488
-
1489
- const bbcKeys = stringToMachineKeys("RUN\n");
1490
- sendRawKeyboard([1000].concat(bbcKeys), false);
1491
- }
1492
-
1493
- function updateUrl() {
1494
- const baseUrl = window.location.origin + window.location.pathname;
1495
- const url = buildUrlFromParams(baseUrl, parsedQuery, paramTypes);
1496
- window.history.pushState(null, null, url);
1497
- }
1498
-
1499
- function splitImage(image) {
1500
- const match = image.match(/(([^:]+):\/?\/?|[!^|])?(.*)/);
1501
- const schema = match[2] || match[1] || "";
1502
- image = match[3];
1503
- return { image: image, schema: schema };
1504
- }
1505
-
1506
- async function reloadSnapshotMedia(media) {
1507
- if (!media) return;
1508
- for (let driveIndex = 0; driveIndex < 2; driveIndex++) {
1509
- const discKey = driveIndex === 0 ? "disc1" : "disc2";
1510
- const imageDataKey = discKey + "ImageData";
1511
- const crcKey = discKey + "Crc32";
1512
-
1513
- // A snapshot from before layout detection has no field, and was contiguous.
1514
- const layout = media[discKey + "Layout"] ?? DiscLayout.contiguous;
1515
-
1516
- let loadedDisc = null;
1517
- if (media[discKey]) {
1518
- // URL-based disc — reload from source
1519
- loadedDisc = await loadDiscImage(media[discKey], layout);
1520
- } else if (media[imageDataKey]) {
1521
- // Locally-loaded disc — reconstruct from embedded image data
1522
- const imageData =
1523
- media[imageDataKey] instanceof Uint8Array
1524
- ? media[imageDataKey]
1525
- : new Uint8Array(Object.values(media[imageDataKey]));
1526
- const discName = media[discKey + "Name"] || "snapshot.ssd";
1527
- loadedDisc = disc.discFor(processor.fdc, discName, imageData, undefined, layout);
1528
- // Retain the image bytes so subsequent saves can re-embed them.
1529
- loadedDisc.setOriginalImage(imageData);
1530
- }
1531
- if (!loadedDisc) continue;
1532
-
1533
- // Verify CRC32 if present
1534
- if (media[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
1535
- if (loadedDisc.originalImageCrc32 !== media[crcKey]) {
1536
- toast(
1537
- `${loadedDisc.name} has changed since this state was saved. The state has been restored anyway and may not run correctly.`,
1538
- { title: "Restoring state" },
1539
- );
1540
- }
1541
- }
1542
-
1543
- putDiscIn(driveIndex, loadedDisc);
1544
- // Only update the URL/query for URL-sourced discs. For embedded
1545
- // (local-file) discs, setting parsedQuery would put a bogus source
1546
- // in the URL and break subsequent saves/reloads.
1547
- if (media[discKey]) {
1548
- if (driveIndex === 0) setDisc1Image(media[discKey]);
1549
- else setDisc2Image(media[discKey]);
1550
- }
1551
- }
1552
- }
1553
-
1554
- async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1555
- if (!discImage) return null;
1556
- const split = splitImage(discImage);
1557
- discImage = split.image;
1558
- const schema = split.schema;
1559
- if (schema[0] === "!" || schema === "local") {
1560
- return disc.localDisc(processor.fdc, discImage, layout, (error) =>
1561
- toast(
1562
- `Browser storage would not take changes to ${discImage} (${errorText(error)}). Use Discs, Download to keep a copy.`,
1563
- { title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
1564
- ),
1565
- );
1566
- }
1567
- // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
1568
- // Consider:
1569
- // * hashing contents and making a local disc image named by original disc hash, save by that, and offer
1570
- // to load the modified disc on load.
1571
- // * popping up a message that notes the disc has changed, and offers a way to make a local image
1572
- // * Dialog box (ugh) saying "is this ok?"
1573
- switch (schema) {
1574
- case "|":
1575
- case "sth": {
1576
- const { name, data, ignored } = await discSth.fetch(discImage);
1577
- reportIgnoredFiles(name, ignored);
1578
- return disc.discFor(processor.fdc, name, data, undefined, layout);
1579
- }
1580
-
1581
- case "hfe":
1582
- return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage), undefined, layout);
1583
-
1584
- case "gd": {
1585
- const splat = discImage.match(/([^/]+)\/?(.*)/);
1586
- let name = "(unknown)";
1587
- if (splat) {
1588
- discImage = splat[1];
1589
- name = splat[2];
1590
- }
1591
- return gdLoad({ name, id: discImage }, layout);
1592
- }
1593
- case "b64data":
1594
- return disc.discFor(processor.fdc, "disk.ssd", atob(discImage), undefined, layout);
1595
-
1596
- case "data": {
1597
- const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
1598
- const { name, data } = await unzipAndReport(arr);
1599
- return disc.discFor(processor.fdc, name, data, undefined, layout);
1600
- }
1601
- case "http":
1602
- case "https":
1603
- case "file": {
1604
- const asUrl = `${schema}://${discImage}`;
1605
- // url may end in query params etc, which can upset the DSD/SSD etc detection on the extension.
1606
- discImage = new URL(asUrl).pathname;
1607
- let discData = await utils.loadData(asUrl);
1608
- if (/\.zip/i.test(discImage)) {
1609
- const unzipped = await unzipAndReport(discData);
1610
- discData = unzipped.data;
1611
- discImage = unzipped.name;
1612
- }
1613
- return disc.discFor(processor.fdc, discImage, discData, undefined, layout);
1614
- }
1615
- default:
1616
- return disc.discFor(processor.fdc, discImage, await disc.load("discs/" + discImage), undefined, layout);
1617
- }
1618
- }
1619
-
1620
- async function loadTapeImage(tapeImage) {
1621
- const split = splitImage(tapeImage);
1622
- tapeImage = split.image;
1623
- const schema = split.schema;
1624
-
1625
- switch (schema) {
1626
- case "|":
1627
- case "sth": {
1628
- const { name, data, ignored } = await tapeSth.fetch(tapeImage);
1629
- reportIgnoredFiles(name, ignored);
1630
- return await loadTapeFromData(name, data, model);
1631
- }
1632
-
1633
- case "data": {
1634
- const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
1635
- const { name, data } = await unzipAndReport(arr);
1636
- return await loadTapeFromData(name, data, model);
1637
- }
1638
-
1639
- case "http":
1640
- case "https":
1641
- case "file": {
1642
- const asUrl = `${schema}://${tapeImage}`;
1643
- // url may end in query params etc, which can upset file handling
1644
- tapeImage = new URL(asUrl).pathname;
1645
- let tapeData = await utils.loadData(asUrl);
1646
- if (/\.zip/i.test(tapeImage)) {
1647
- const unzipped = await unzipAndReport(tapeData);
1648
- tapeData = unzipped.data;
1649
- tapeImage = unzipped.name;
1650
- }
1651
- return await loadTapeFromData(tapeImage, tapeData, model);
1652
- }
1653
-
1654
- default: {
1655
- const tapePath = "tapes/" + tapeImage;
1656
- let tapeData = await utils.loadData(tapePath);
1657
- let tapeName = tapeImage;
1658
- if (/\.zip/i.test(tapeName)) {
1659
- const unzipped = await unzipAndReport(tapeData);
1660
- tapeData = unzipped.data;
1661
- tapeName = unzipped.name;
1662
- }
1663
- return await loadTapeFromData(tapeName, tapeData, model);
1664
- }
1665
- }
1666
- }
1667
-
1668
- document.getElementById("disc_load").addEventListener("change", async function (evt) {
1669
- if (evt.target.files.length === 0) return;
1670
- utils.noteEvent("local", "click"); // NB no filename here
1671
- const file = evt.target.files[0];
1672
- try {
1673
- await loadHTMLFile(file);
1674
- } catch (error) {
1675
- reportLoadFailure(file.name, error);
1676
- }
1677
- evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1678
- });
1679
-
1680
- document.getElementById("fs_load").addEventListener("change", async function (evt) {
1681
- if (evt.target.files.length === 0) return;
1682
- utils.noteEvent("local", "click"); // NB no filename here
1683
- const file = evt.target.files[0];
1684
- try {
1685
- await loadSCSIFile(file);
1686
- } catch (error) {
1687
- reportLoadFailure(file.name, error);
1688
- }
1689
- evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1690
- });
1691
-
1692
- document.getElementById("tape_load").addEventListener("change", async function (evt) {
1693
- if (evt.target.files.length === 0) return;
1694
- const file = evt.target.files[0];
1695
- utils.noteEvent("local", "clickTape"); // NB no filename here
1696
-
1697
- try {
1698
- let tapeData = await readFileAsBinaryString(file);
1699
- let tapeName = file.name;
1700
- if (/\.zip/i.test(tapeName)) {
1701
- const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
1702
- tapeData = unzipped.data;
1703
- tapeName = unzipped.name;
1704
- }
1705
- setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1706
- delete parsedQuery.tape;
1707
- updateUrl();
1708
- bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
1709
- } catch (error) {
1710
- reportLoadFailure(file.name, error);
1711
- }
1712
-
1713
- evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1714
- });
1715
-
1716
- function anyModalsVisible() {
1717
- return document.querySelectorAll(".modal.show").length !== 0;
1718
- }
1719
-
1720
- let modalSavedRunning = false;
1721
- document.addEventListener("show.bs.modal", function () {
1722
- if (!anyModalsVisible()) modalSavedRunning = running;
1723
- if (running) stop(false);
1724
- });
1725
- document.addEventListener("hidden.bs.modal", function () {
1726
- if (!anyModalsVisible() && modalSavedRunning) {
1727
- go();
1728
- }
1729
- });
1730
-
1731
- const loadingDialog = document.getElementById("loading-dialog");
1732
- const loadingDialogModal = new bootstrap.Modal(loadingDialog);
1733
- const googleDriveAuth = document.getElementById("google-drive-auth");
1734
-
1735
- function popupLoading(msg) {
1736
- loadingDialog.querySelector(".loading").textContent = msg;
1737
- googleDriveAuth.style.display = "none";
1738
- loadingDialogModal.show();
1739
- }
1740
-
1741
- function loadingFinished(message) {
1742
- googleDriveAuth.style.display = "none";
1743
- loadingDialogModal.hide();
1744
- if (message) toast(message);
1745
- }
1746
-
1747
- const googleDrive = new GoogleDriveLoader();
1748
- const googleDriveEl = document.getElementById("google-drive");
1749
-
1750
- async function gdAuth(imm) {
1751
- try {
1752
- return await googleDrive.authorize(imm);
1753
- } catch (err) {
1754
- console.log("Error handling google auth: " + err);
1755
- googleDriveEl.querySelector(".loading").textContent =
1756
- "There was an error accessing your Google Drive account: " + err;
1757
- }
1758
- }
1759
-
1760
- let googleDriveLoadingResolve, googleDriveLoadingReject;
1761
- document.querySelector("#google-drive-auth form").addEventListener("submit", async function (e) {
1762
- googleDriveAuth.style.display = "none";
1763
- e.preventDefault();
1764
- const authed = await gdAuth(false);
1765
- if (authed) googleDriveLoadingResolve();
1766
- else googleDriveLoadingReject(new Error("Unable to authorize Google Drive"));
1767
- });
1768
-
1769
- async function gdLoad(cat, layout) {
1770
- popupLoading("Loading '" + cat.name + "' from Google Drive");
1771
- try {
1772
- const available = await googleDrive.initialise();
1773
- console.log("Google Drive available =", available);
1774
- if (!available) throw new Error("Google Drive is not available");
1775
-
1776
- const authed = await gdAuth(true);
1777
- console.log("Google Drive authed=", authed);
1778
-
1779
- if (!authed) {
1780
- await new Promise(function (resolve, reject) {
1781
- googleDriveLoadingResolve = resolve;
1782
- googleDriveLoadingReject = reject;
1783
- googleDriveAuth.style.display = "";
1784
- });
1785
- }
1786
-
1787
- const ssd = await googleDrive.load(processor.fdc, cat.id, layout);
1788
- console.log("Google Drive loading finished");
1789
- loadingFinished();
1790
- if (!ssd.savesChanges) {
1791
- toast(`${cat.name} is read only on Google Drive, so changes to it are not written back.`, {
1792
- title: "Google Drive",
1793
- quietKey: "quietDriveReadOnly",
1794
- });
1795
- }
1796
- return ssd;
1797
- } catch (error) {
1798
- console.error("Google Drive loading error:", error);
1799
- loadingFinished(`Unable to load ${cat.name} from Google Drive: ${errorText(error)}`);
1800
- }
1801
- }
1802
-
1803
- const googleDriveModal = new bootstrap.Modal(googleDriveEl);
1804
- // Loading the Google client holds the main thread for ~100ms, so it waits for
1805
- // someone to ask for Drive.
1806
- document.getElementById("open-drive-link").addEventListener("click", async function () {
1807
- try {
1808
- await googleDrive.initialise();
1809
- } catch (error) {
1810
- toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
1811
- return false;
1812
- }
1813
- const authed = await gdAuth(false);
1814
- if (authed) {
1815
- googleDriveModal.show();
1816
- }
1817
- return false;
1818
- });
1819
- googleDriveEl.addEventListener("show.bs.modal", async function () {
1820
- const gdLoading = googleDriveEl.querySelector(".loading");
1821
- gdLoading.textContent = "Loading...";
1822
- gdLoading.style.display = "";
1823
- for (const el of googleDriveEl.querySelectorAll("li:not(.template)")) el.remove();
1824
- let cat;
1825
- try {
1826
- cat = await googleDrive.listFiles();
1827
- } catch (error) {
1828
- console.error("Error listing Google Drive files:", error);
1829
- gdLoading.textContent = `Unable to list your Google Drive files: ${errorText(error)}`;
1830
- return;
1831
- }
1832
- const dbList = googleDriveEl.querySelector(".list");
1833
- gdLoading.style.display = "none";
1834
- const template = dbList.querySelector(".template");
1835
- for (const item of cat) {
1836
- const row = template.cloneNode(true);
1837
- row.classList.remove("template");
1838
- dbList.appendChild(row);
1839
- row.querySelector(".name").textContent = item.name;
1840
- row.addEventListener("click", async function () {
1841
- utils.noteEvent("google-drive", "click", item.name);
1842
- setDisc1Image(`gd:${item.id}/${item.name}`);
1843
- googleDriveModal.hide();
1844
- const ssd = await gdLoad(item, layoutForDrive(0));
1845
- if (ssd) putDiscIn(0, ssd);
1846
- });
1847
- }
1848
- });
1849
- const discList = document.getElementById("disc-list");
1850
- const discTemplate = discList.querySelector(".template");
1851
- for (const image of availableImages) {
1852
- const elem = discTemplate.cloneNode(true);
1853
- elem.classList.remove("template");
1854
- discList.appendChild(elem);
1855
- elem.querySelector(".name").textContent = image.name;
1856
- elem.querySelector(".description").textContent = image.desc;
1857
- elem.addEventListener("click", async function () {
1858
- utils.noteEvent("images", "click", image.file);
1859
- setDisc1Image(image.file);
1860
- $discsModal.hide();
1861
- try {
1862
- putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1863
- } catch (error) {
1864
- reportLoadFailure(`${image.name} (${image.file})`, error);
1865
- }
1866
- });
1867
- }
1868
-
1869
- document.querySelector("#google-drive form").addEventListener("submit", async function (e) {
1870
- e.preventDefault();
1871
- let name = document.querySelector("#google-drive .disc-name").value;
1872
- if (!name) return;
1873
-
1874
- popupLoading("Connecting to Google Drive");
1875
- googleDriveModal.hide();
1876
- popupLoading("Creating '" + name + "' on Google Drive");
1877
-
1878
- let data;
1879
- if (document.querySelector("#google-drive .create-from-existing").checked) {
1880
- const discType = disc.guessDiscTypeFromName(name);
1881
- try {
1882
- data = discType.saver(processor.fdc.drives[0].disc);
1883
- } catch (e) {
1884
- loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(e)}`);
1885
- return;
1886
- }
1887
- name = replaceOrAddExtension(name, discType.extension);
1888
- console.log(`Saving existing disc: ${name}`);
1889
- } else {
1890
- // TODO support HFE, I guess?
1891
- const discType = disc.guessDiscTypeFromName(name);
1892
- if (!discType.byteSize) {
1893
- throw new Error(`Cannot create blank disc of type ${discType.extension} - unknown size`);
1894
- }
1895
- data = new Uint8Array(discType.byteSize);
1896
- if (discType.supportsCatalogue) {
1897
- discType.setDiscName(data, name);
1898
- }
1899
- console.log(`Creating blank: ${name}`);
1900
- }
1901
-
1902
- try {
1903
- const result = await googleDrive.create(processor.fdc, name, data);
1904
- setDisc1Image("gd:" + result.fileId + "/" + name);
1905
- putDiscIn(0, result.disc);
1906
- loadingFinished();
1907
- } catch (error) {
1908
- console.error(`Error creating Google Drive disc: ${error}`, error);
1909
- loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(error)}`);
1910
- }
1911
- });
1912
-
1913
- document.getElementById("download-drive-link").addEventListener("click", function () {
1914
- const disc = processor.fdc.drives[0].disc;
1915
- const save = (options) =>
1916
- downloadDriveData(toSsdOrDsd(disc, options), disc.name, disc.isDoubleSided ? ".dsd" : ".ssd");
1917
- try {
1918
- save();
1919
- } catch (e) {
1920
- areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
1921
- save({ force: true }),
1922
- );
1923
- }
1924
- });
1925
-
1926
- document.getElementById("download-drive-hfe-link").addEventListener("click", function () {
1927
- const disc = processor.fdc.drives[0].disc;
1928
- const data = toHfe(disc);
1929
- const name = disc.name;
1930
-
1931
- downloadDriveData(data, name, ".hfe");
1932
- });
1933
-
1934
- document.getElementById("download-filestore-link").addEventListener("click", function () {
1935
- downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
1936
- });
1937
-
1938
- function hardReset() {
1939
- if (rewindUI) {
1940
- rewindUI.close();
1941
- rewindBuffer.clear();
1942
- rewindUI.updateButtonState();
1943
- }
1944
- processor.reset(true);
1945
- }
1946
-
1947
- document.getElementById("hard-reset").addEventListener("click", function (event) {
1948
- hardReset();
1949
- event.preventDefault();
1950
- });
385
+ document.getElementById("hard-reset").addEventListener("click", function (event) {
386
+ hardReset();
387
+ event.preventDefault();
388
+ });
1951
389
 
1952
390
  document.getElementById("soft-reset").addEventListener("click", function (event) {
1953
391
  processor.reset(false);
1954
392
  event.preventDefault();
1955
393
  });
1956
394
 
1957
- document.getElementById("save-state").addEventListener("click", async function (event) {
1958
- event.preventDefault();
1959
- const wasRunning = running;
1960
- if (running) stop(false);
1961
- try {
1962
- const media = {};
1963
- if (parsedQuery.disc1 || parsedQuery.disc) media.disc1 = parsedQuery.disc1 || parsedQuery.disc;
1964
- if (parsedQuery.disc2) media.disc2 = parsedQuery.disc2;
1965
-
1966
- // For each drive with a disc loaded, include CRC32 for verification
1967
- // and embed original image data if no URL source exists (local file).
1968
- const drives = processor.fdc.drives;
1969
- for (let driveIndex = 0; driveIndex < 2; driveIndex++) {
1970
- const driveDisc = drives[driveIndex].disc;
1971
- if (!driveDisc || driveDisc.originalImageCrc32 == null) continue;
1972
- const discKey = driveIndex === 0 ? "disc1" : "disc2";
1973
- const crcKey = discKey + "Crc32";
1974
- media[crcKey] = driveDisc.originalImageCrc32;
1975
- // The snapshot's dirty tracks are indexed by physical track, so restoring has to lay
1976
- // the disc out the way this one was rather than work it out again.
1977
- media[discKey + "Layout"] = driveDisc.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
1978
- if (!media[discKey] && driveDisc.originalImageData) {
1979
- media[discKey + "ImageData"] = driveDisc.originalImageData;
1980
- media[discKey + "Name"] = driveDisc.name;
1981
- }
1982
- }
1983
-
1984
- const snapshot = createSnapshot(processor, model, Object.keys(media).length > 0 ? media : undefined);
1985
- const json = snapshotToJSON(snapshot);
1986
- const blob = await compressBlob(new Blob([json]));
1987
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1988
- downloadBlob(blob, `jsbeeb-${model.name}-${timestamp}.json.gz`);
1989
- } catch (e) {
1990
- showError("saving state", e);
1991
- }
1992
- if (wasRunning) go();
1993
- });
1994
-
1995
- async function loadStateFromFile(file, preReadBuffer) {
1996
- const wasRunning = running;
1997
- if (running) stop(false);
1998
- try {
1999
- const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
2000
- let snapshot;
2001
- if (isBemSnapshot(arrayBuffer)) {
2002
- snapshot = await parseBemSnapshot(arrayBuffer);
2003
- } else if (isUefSnapshot(arrayBuffer)) {
2004
- snapshot = parseUefSnapshot(arrayBuffer);
2005
- } else {
2006
- // Detect gzip (magic bytes 0x1f 0x8b) or plain JSON
2007
- const bytes = new Uint8Array(arrayBuffer);
2008
- let text;
2009
- if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
2010
- const decompressed = await decompressBlob(new Blob([arrayBuffer]));
2011
- text = await decompressed.text();
2012
- } else {
2013
- text = new TextDecoder().decode(arrayBuffer);
2014
- }
2015
- snapshot = snapshotFromJSON(text);
2016
- }
2017
- if (!isSameModel(snapshot.model, model.name) || hasCoProcessor(snapshot) !== processor.hasTube) {
2018
- // Model or co-processor mismatch: stash state and reload with a matching machine
2019
- sessionStorage.setItem("jsbeeb-pending-state", snapshotToJSON(snapshot));
2020
- const newQuery = { ...parsedQuery, model: snapshot.model, coProcessor: hasCoProcessor(snapshot) };
2021
- const baseUrl = window.location.origin + window.location.pathname;
2022
- window.location.href = buildUrlFromParams(baseUrl, newQuery, paramTypes);
2023
- return;
2024
- }
2025
- // Order matters: reload disc media first so the base disc is in the
2026
- // drive before restoreSnapshot applies dirty track overlays on top.
2027
- await reloadSnapshotMedia(snapshot.media);
2028
- restoreSnapshot(processor, model, snapshot);
2029
- // Force a repaint so the display updates even while paused
2030
- video.paint();
2031
- } catch (e) {
2032
- showError("loading state", e);
2033
- }
2034
- if (wasRunning) go();
2035
- }
2036
-
2037
- function isSnapshotFile(filename, arrayBuffer) {
2038
- const lower = filename.toLowerCase();
2039
- if (lower.endsWith(".snp") || lower.endsWith(".json") || lower.endsWith(".json.gz") || lower.endsWith(".gz"))
2040
- return true;
2041
- // .uef can be either a BeebEm save state or a regular tape image - check content
2042
- if (lower.endsWith(".uef") && arrayBuffer) return isUefSnapshot(arrayBuffer);
2043
- return false;
2044
- }
2045
-
2046
- document.getElementById("load-state").addEventListener("change", async function (event) {
2047
- const file = event.target.files[0];
2048
- if (!file) return;
2049
- event.target.value = "";
2050
- await loadStateFromFile(file);
395
+ document.getElementById("download-filestore-link").addEventListener("click", function () {
396
+ downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
2051
397
  });
2052
398
 
2053
- for (const link of document.querySelectorAll("#tape-menu a")) {
2054
- link.addEventListener("click", function (e) {
2055
- const type = e.target.dataset.id;
2056
- if (type === undefined) return;
2057
-
2058
- if (type === "rewind") {
2059
- console.log("Rewinding tape to the start");
2060
- if (model.isAtom) {
2061
- processor.atomppia.stopTape();
2062
- processor.atomppia.rewindTape();
2063
- updateTapeButton();
2064
- } else {
2065
- processor.acia.rewindTape();
2066
- }
2067
- } else {
2068
- console.log("unknown type", type);
2069
- }
2070
- });
2071
- }
2072
-
2073
- const tapePlayStopBtn = document.getElementById("tape-play-stop");
2074
- const tapeControlHeader = document.getElementById("tape-control-header");
2075
- const tapeControlCell = document.getElementById("tape-control-cell");
2076
-
2077
- function updateTapeButton() {
2078
- if (!model.isAtom) return;
2079
- const playing = processor.atomppia.motorOn;
2080
- const label = playing ? "Stop cassette" : "Play cassette";
2081
- tapePlayStopBtn.textContent = playing ? "\u25A0" : "\u25B6";
2082
- tapePlayStopBtn.title = label;
2083
- tapePlayStopBtn.setAttribute("aria-label", label);
2084
- tapePlayStopBtn.classList.toggle("playing", playing);
2085
- }
2086
-
2087
- function showTapeControl(visible) {
2088
- const display = visible ? "" : "none";
2089
- tapeControlHeader.style.display = display;
2090
- tapeControlCell.style.display = display;
2091
- }
2092
-
2093
- function updateLedVisibility() {
2094
- const bbcDisplay = model.isAtom ? "none" : "";
2095
- for (const el of document.querySelectorAll(".bbc-only")) {
2096
- el.style.display = bbcDisplay;
2097
- }
2098
- showTapeControl(model.isAtom);
2099
- }
399
+ if (Object.hasOwn(parsedQuery, "about")) modals.show("info");
400
+ if (Object.hasOwn(parsedQuery, "pp-tos")) modals.show("pp-tos");
2100
401
 
2101
- updateLedVisibility();
402
+ // ------------------------------------------------------------------------
403
+ // Start it up.
404
+ // ------------------------------------------------------------------------
2102
405
 
2103
- tapePlayStopBtn.addEventListener("click", () => {
2104
- if (processor.atomppia.motorOn) {
2105
- processor.atomppia.stopTape();
2106
- } else {
2107
- processor.atomppia.playTape();
2108
- }
2109
- updateTapeButton();
406
+ const basicNeedsRun = parsedQuery.loadBasic !== undefined && needsAutoboot === "run";
407
+ if (parsedQuery.loadBasic) needsAutoboot = "";
408
+ const startPromise = machine.start({
409
+ media,
410
+ drives,
411
+ autoBoot,
412
+ discImage,
413
+ secondDiscImage,
414
+ tape: parsedQuery.tape,
415
+ mmcImage,
416
+ loadBasic: parsedQuery.loadBasic,
417
+ embedBasic: parsedQuery.embedBasic,
418
+ basicNeedsRun,
2110
419
  });
2111
420
 
2112
- function Light(name) {
2113
- const dom = document.getElementById(name);
2114
- let on = false;
2115
- this.update = function (val) {
2116
- if (val === on) return;
2117
- on = val;
2118
- dom.classList.toggle("on", on);
2119
- };
2120
- }
2121
-
2122
- const cassette = new Light("motorlight");
2123
- const caps = new Light("capslight");
2124
- const shift = new Light("shiftlight");
2125
- const drive0 = new Light("drive0");
2126
- const drive1 = new Light("drive1");
2127
- const network = new Light("networklight");
2128
-
2129
- syncLights = function () {
2130
- if (model.isAtom) {
2131
- cassette.update(processor.atomppia.motorOn);
2132
- } else {
2133
- caps.update(processor.sysvia.capsLockLight);
2134
- shift.update(processor.sysvia.shiftLockLight);
2135
- drive0.update(processor.fdc.motorOn[0]);
2136
- drive1.update(processor.fdc.motorOn[1]);
2137
- cassette.update(processor.acia.motorOn);
2138
- if (processor.econet) {
2139
- network.update(processor.econet.activityLight());
2140
- }
2141
- }
2142
- };
2143
-
2144
- const startPromise = (async () => {
2145
- await Promise.all([audioHandler.initialise(), processor.initialise()]);
2146
-
2147
- // Wire up the composite RS-423 handler now that the touchscreen exists.
2148
- setupRs423Handler();
2149
-
2150
- // Ideally would start the loads first. But their completion needs the FDC from the processor
2151
- const imageLoads = [];
2152
-
2153
- function startImageLoad(description, load) {
2154
- const loading = (async () => {
2155
- try {
2156
- await load();
2157
- } catch (error) {
2158
- reportLoadFailure(description, error);
2159
- }
2160
- })();
2161
- imageLoads.push(loading);
2162
- return loading;
2163
- }
2164
-
2165
- if (discImage) {
2166
- startImageLoad(`disc ${discImage}`, async () =>
2167
- putDiscIn(0, await loadDiscImage(discImage, layoutForDrive(0))),
2168
- );
2169
- }
2170
-
2171
- if (secondDiscImage) {
2172
- startImageLoad(`disc ${secondDiscImage}`, async () =>
2173
- putDiscIn(1, await loadDiscImage(secondDiscImage, layoutForDrive(1))),
2174
- );
2175
- }
2176
-
2177
- if (parsedQuery.tape) {
2178
- startImageLoad(`tape ${parsedQuery.tape}`, async () => setProcessorTape(await loadTapeImage(parsedQuery.tape)));
2179
- }
2180
-
2181
- if (mmcImage && model.isAtom) {
2182
- startImageLoad(`MMC image ${mmcImage}`, async () => processor.atommc.SetMMCData(await LoadSD(mmcImage)));
2183
- }
2184
-
2185
- async function insertBasic(getBasicPromise, needsRun) {
2186
- const prog = await getBasicPromise;
2187
- const t = await tokeniser.create();
2188
- const tokenised = await t.tokenise(prog);
2189
-
2190
- const idleAddr = processor.model.isMaster ? 0xe7e6 : 0xe581;
2191
- const hook = processor.debugInstruction.add(function (addr) {
2192
- if (addr !== idleAddr) return;
2193
- const page = processor.readmem(0x18) << 8;
2194
- for (let i = 0; i < tokenised.length; ++i) {
2195
- processor.writemem(page + i, tokenised.charCodeAt(i));
2196
- }
2197
- // Set VARTOP (0x12/3) and TOP(0x02/3)
2198
- const end = page + tokenised.length;
2199
- const endLow = end & 0xff;
2200
- const endHigh = (end >>> 8) & 0xff;
2201
- processor.writemem(0x02, endLow);
2202
- processor.writemem(0x03, endHigh);
2203
- processor.writemem(0x12, endLow);
2204
- processor.writemem(0x13, endHigh);
2205
- hook.remove();
2206
- if (needsRun) {
2207
- autoRunBasic();
2208
- }
2209
- });
2210
- }
2211
-
2212
- if (parsedQuery.loadBasic) {
2213
- const needsRun = needsAutoboot === "run";
2214
- needsAutoboot = "";
2215
-
2216
- await startImageLoad(`BASIC program ${parsedQuery.loadBasic}`, () =>
2217
- insertBasic(
2218
- (async () => {
2219
- const data = await utils.loadData(parsedQuery.loadBasic);
2220
- return String.fromCharCode.apply(null, data);
2221
- })(),
2222
- needsRun,
2223
- ),
2224
- );
2225
- }
2226
-
2227
- if (parsedQuery.embedBasic) {
2228
- await startImageLoad("the BASIC program from the URL", () =>
2229
- insertBasic(Promise.resolve(parsedQuery.embedBasic), true),
2230
- );
2231
- }
2232
-
2233
- return Promise.all(imageLoads);
2234
- })();
2235
-
2236
421
  (async () => {
2237
422
  try {
2238
423
  await startPromise;
2239
424
 
2240
425
  switch (needsAutoboot) {
2241
426
  case "boot":
2242
- showAutoboot(true);
2243
- autoboot(discImage);
427
+ autobootTicks.show(true);
428
+ autoBoot.boot(discImage);
2244
429
  break;
2245
430
  case "type":
2246
- autoBootType(autoType);
431
+ autoBoot.type(autoType);
2247
432
  break;
2248
433
  case "chain":
2249
- autoChainTape();
434
+ autoBoot.chainTape();
2250
435
  break;
2251
436
  case "run":
2252
- autoRunTape();
437
+ autoBoot.runTape();
2253
438
  break;
2254
439
  default:
2255
- showAutoboot(false);
440
+ autobootTicks.show(false);
2256
441
  break;
2257
442
  }
2258
443
 
@@ -2260,450 +445,28 @@ const startPromise = (async () => {
2260
445
  dbgr.setPatch(parsedQuery.patch);
2261
446
  }
2262
447
 
2263
- // Restore pending state from a cross-model load (sessionStorage)
2264
- const pendingState = sessionStorage.getItem("jsbeeb-pending-state");
2265
- if (pendingState) {
2266
- sessionStorage.removeItem("jsbeeb-pending-state");
2267
- try {
2268
- const snapshot = snapshotFromJSON(pendingState);
2269
- // Order matters: reload disc media first so the base disc is in the
2270
- // drive before restoreSnapshot applies dirty track overlays on top.
2271
- await reloadSnapshotMedia(snapshot.media);
2272
- restoreSnapshot(processor, model, snapshot);
2273
- processor.execute(40000);
2274
- } catch (e) {
2275
- showError("restoring saved state", e);
2276
- }
2277
- }
448
+ // Restore the state a cross-model reload stashed, if there is one.
449
+ await snapshots.restorePendingState();
2278
450
 
2279
- go();
451
+ loop.go();
2280
452
  } catch (error) {
2281
453
  console.error("Error initialising emulator:", error);
2282
- showError("initialising", error);
454
+ modals.showError("initialising", error);
2283
455
  }
2284
456
  })();
2285
457
 
2286
- const aysEl = document.getElementById("are-you-sure");
2287
- const aysModal = new bootstrap.Modal(aysEl);
2288
-
2289
- function areYouSure(message, yesText, noText, yesFunc) {
2290
- const yesButton = aysEl.querySelector(".ays-yes");
2291
- aysEl.querySelector(".context").textContent = message;
2292
- aysEl.querySelector(".ays-no").textContent = noText;
2293
- yesButton.textContent = yesText;
2294
- let confirmed = false;
2295
- const onYes = () => {
2296
- confirmed = true;
2297
- aysModal.hide();
2298
- };
2299
- yesButton.addEventListener("click", onYes, { once: true });
2300
- // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
2301
- aysEl.addEventListener(
2302
- "hidden.bs.modal",
2303
- () => {
2304
- yesButton.removeEventListener("click", onYes);
2305
- if (confirmed) yesFunc();
2306
- },
2307
- { once: true },
2308
- );
2309
- aysModal.show();
2310
- }
2311
-
2312
- function benchmarkCpu(numCycles) {
2313
- numCycles = numCycles || 10 * 1000 * 1000;
2314
- const oldFS = frameSkip;
2315
- frameSkip = 1000000;
2316
- const startTime = performance.now();
2317
- processor.execute(numCycles);
2318
- const endTime = performance.now();
2319
- frameSkip = oldFS;
2320
- const msTaken = endTime - startTime;
2321
- const virtualMhz = numCycles / msTaken / 1000;
2322
- console.log("Took " + msTaken + "ms to execute " + numCycles + " cycles");
2323
- console.log("Virtual " + virtualMhz.toFixed(2) + "MHz");
2324
- }
2325
-
2326
- function benchmarkVideo(numCycles) {
2327
- numCycles = numCycles || 10 * 1000 * 1000;
2328
- const oldFS = frameSkip;
2329
- frameSkip = 1000000;
2330
- const startTime = performance.now();
2331
- video.polltime(numCycles);
2332
- const endTime = performance.now();
2333
- frameSkip = oldFS;
2334
- const msTaken = endTime - startTime;
2335
- const virtualMhz = numCycles / msTaken / 1000;
2336
- console.log("Took " + msTaken + "ms to execute " + numCycles + " video cycles");
2337
- console.log("Virtual " + virtualMhz.toFixed(2) + "MHz");
2338
- }
2339
-
2340
- function profileCpu(arg) {
2341
- console.profile("CPU");
2342
- benchmarkCpu(arg);
2343
- console.profileEnd();
2344
- }
2345
-
2346
- function profileVideo(arg) {
2347
- console.profile("Video");
2348
- benchmarkVideo(arg);
2349
- console.profileEnd();
2350
- }
2351
-
2352
- let last = 0;
2353
- let lastEnd = 0;
2354
-
2355
- function VirtualSpeedUpdater() {
2356
- this.cycles = 0;
2357
- this.time = 0;
2358
- this.v = document.querySelector(".virtualMHz");
2359
- this.header = document.getElementById("virtual-mhz-header");
2360
- this.speedy = false;
2361
-
2362
- this.update = function (cycles, time, speedy) {
2363
- this.cycles += cycles;
2364
- this.time += time;
2365
- this.speedy = speedy;
2366
- };
2367
-
2368
- this.display = function () {
2369
- // MRG would be nice to graph instantaneous speed to get some idea where the time goes.
2370
- if (this.cycles) {
2371
- const thisMHz = this.cycles / this.time / 1000;
2372
- this.v.textContent = thisMHz.toFixed(1);
2373
- if (this.cycles >= 10 * cpuSpeed) {
2374
- this.cycles = this.time = 0;
2375
- }
2376
- this.header.style.color = this.speedy ? "red" : "white";
2377
- }
2378
- setTimeout(this.display.bind(this), 3333);
2379
- };
2380
-
2381
- this.display();
2382
- }
2383
-
2384
- const virtualSpeedUpdater = new VirtualSpeedUpdater();
2385
-
2386
- const rewindBuffer = new RewindBuffer(30);
2387
- let rewindCycleCounter = 0;
2388
- const RewindCaptureInterval = 50; // emulated frames, ~1 second
2389
- const RewindCaptureCycles = (RewindCaptureInterval * clocksPerSecond) / 50;
2390
-
2391
- // Under ?audioDebug, one console line per second in which the emulator sat
2392
- // idle between ticks or a tick ran long, or the audio queue underran or
2393
- // dropped, so a click can be matched to a cause. The sound chip posts samples
2394
- // throughout execute(), so only the idle time starves the audio queue.
2395
- const AudioDebugLogIntervalMs = 1000;
2396
- const AudioDebugSlowTickMs = 30;
2397
- const audioDebugLog = { start: 0, ticks: 0, cycles: 0, maxIdle: 0, maxExecute: 0, maxPaint: 0, maxSnapshot: 0 };
2398
- const AudioDebugSlowPresentMs = 30;
2399
-
2400
- function logAudioDebugTick(now, cycles, idleMs, executeMs, paintMs, snapshotMs) {
2401
- const log = audioDebugLog;
2402
- if (log.start === 0) log.start = now;
2403
- log.ticks++;
2404
- log.cycles += cycles;
2405
- log.maxIdle = Math.max(log.maxIdle, idleMs);
2406
- log.maxExecute = Math.max(log.maxExecute, executeMs);
2407
- log.maxPaint = Math.max(log.maxPaint, paintMs);
2408
- log.maxSnapshot = Math.max(log.maxSnapshot, snapshotMs);
2409
- if (now - log.start < AudioDebugLogIntervalMs) return;
2410
- const audio = audioHandler.takeEventCounts();
2411
- const present = presentMsMax;
2412
- presentMsMax = 0;
2413
- const leadMin = Number.isFinite(audio.leadMinMs) ? `${audio.leadMinMs.toFixed(1)}ms` : "(no stats)";
2414
- if (
2415
- log.maxIdle > AudioDebugSlowTickMs ||
2416
- log.maxExecute > AudioDebugSlowTickMs ||
2417
- present > AudioDebugSlowPresentMs ||
2418
- audio.stall ||
2419
- audio.skip
2420
- ) {
2421
- console.log(
2422
- `${(now / 1000).toFixed(0)}s: ${log.ticks} ticks emulating ${((1000 * log.cycles) / clocksPerSecond).toFixed(0)}ms, ` +
2423
- `idle max ${log.maxIdle.toFixed(0)}ms, ` +
2424
- `execute max ${log.maxExecute.toFixed(0)}ms (paint ${log.maxPaint.toFixed(1)}ms), ` +
2425
- `present max ${present.toFixed(0)}ms, snapshot ${log.maxSnapshot.toFixed(1)}ms; ` +
2426
- `audio lead min ${leadMin}, stalls ${audio.stall}, skipped ${audio.skip.toFixed(0)}ms`,
2427
- );
2428
- }
2429
- log.start = now;
2430
- log.ticks = log.cycles = log.maxIdle = log.maxExecute = log.maxPaint = log.maxSnapshot = 0;
2431
- }
2432
-
2433
- rewindUI = new RewindUI({
2434
- rewindBuffer,
2435
- processor,
2436
- video,
2437
- captureInterval: RewindCaptureInterval,
2438
- stop,
2439
- go,
2440
- isRunning: () => running,
2441
- });
2442
- rewindUI.updateButtonState();
2443
-
2444
- if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
2445
- else document.getElementById("disc-visualiser-open").classList.add("disabled");
2446
-
2447
- for (const item of document.querySelectorAll(".drive-tracks")) {
2448
- const driveIndex = Number(item.dataset.drive);
2449
- const drive = processor.fdc?.drives[driveIndex];
2450
- const fixed = drive ? tracksPerStepForDrive(driveIndex) : undefined;
2451
- if (fixed !== undefined) drive.tracksPerStep = fixed;
2452
- for (const button of driveTracksButtons(driveIndex)) {
2453
- button.disabled = !drive;
2454
- button.addEventListener("click", (event) => {
2455
- // Setting a switch is not picking from a menu, so leave the menu where it is.
2456
- event.stopPropagation();
2457
- drive.tracksPerStep = tracksPerStepFor(button.dataset.tracks);
2458
- showDriveTracks(driveIndex);
2459
- });
2460
- }
2461
- if (drive) showDriveTracks(driveIndex);
2462
- }
2463
-
2464
- // A timer, not requestAnimationFrame: a display presentation stall withholds
2465
- // animation frames, and with them the sound chip's samples (issue #885).
2466
- const TickMs = 10;
2467
- let tickToken = null;
2468
-
2469
- // A user-blocking task runs ahead of rendering and ordinary timers, so a stuck
2470
- // compositor does not hold the tick off too.
2471
- function scheduleTick(delayMs) {
2472
- const token = (tickToken = {});
2473
- const fire = () => {
2474
- if (tickToken === token) tick();
2475
- };
2476
- if (window.scheduler?.postTask) window.scheduler.postTask(fire, { delay: delayMs, priority: "user-blocking" });
2477
- else window.setTimeout(fire, delayMs);
2478
- }
2479
-
2480
- function tick() {
2481
- if (!running) {
2482
- last = 0;
2483
- return;
2484
- }
2485
- const now = performance.now();
2486
-
2487
- const motorOn = processor.acia.motorOn;
2488
- const speedy = fastAsPossible || (fastTape && motorOn);
2489
-
2490
- // In speedy mode, we still run all the state machines accurately
2491
- // but we paint less often because painting is the most expensive
2492
- // part of jsbeeb at this time.
2493
- // We need need to paint per odd number of frames so that interlace
2494
- // modes, i.e. MODE 7, still look ok.
2495
- video.frameSkipCount = speedy ? 9 : 0;
2496
-
2497
- scheduleTick(speedy ? 0 : TickMs);
2498
-
2499
- gamepad.update(processor.sysvia);
2500
- syncLights();
2501
- if (last !== 0) {
2502
- let cycles;
2503
- if (!speedy) {
2504
- const sinceLast = Math.max(0, now - last);
2505
- cycles = (sinceLast * clocksPerSecond) / 1000;
2506
- cycles = Math.min(cycles, MaxCyclesPerTick);
2507
- } else {
2508
- cycles = clocksPerSecond / 50;
2509
- }
2510
- cycles |= 0;
2511
- try {
2512
- if (!processor.execute(cycles)) {
2513
- stop(true);
2514
- }
2515
- audioHandler.flushChipEvents();
2516
- const end = performance.now();
2517
- virtualSpeedUpdater.update(cycles, end - now, speedy);
2518
- let snapshotMs = 0;
2519
- rewindCycleCounter += cycles;
2520
- if (rewindCycleCounter >= RewindCaptureCycles) {
2521
- rewindCycleCounter -= RewindCaptureCycles;
2522
- rewindBuffer.push(processor.snapshotState());
2523
- rewindUI.updateButtonState();
2524
- snapshotMs = performance.now() - end;
2525
- }
2526
- if (audioStatsNode)
2527
- logAudioDebugTick(now, cycles, speedy ? 0 : now - lastEnd, end - now, paintMsThisTick, snapshotMs);
2528
- paintMsThisTick = 0;
2529
- } catch (e) {
2530
- running = false;
2531
- utils.noteEvent("exception", "thrown", e.stack);
2532
- dbgr.debug(processor.pc);
2533
- throw e;
2534
- }
2535
- if (keyboard.postFrameShouldPause()) {
2536
- stop(false);
2537
- }
2538
- }
2539
- last = Math.max(last, now);
2540
- lastEnd = performance.now();
2541
- }
2542
-
2543
- function run() {
2544
- scheduleTick(0);
2545
- }
2546
-
2547
- // A change of audio buffer depth is taken by the picture, not the sound:
2548
- // gaining lead emulates ahead at once; losing it moves `last` forward so the
2549
- // ticks emulate nothing until the queue has drained by that much.
2550
- let emulationLeadMs = 0;
2551
-
2552
- function setEmulationLead(leadMs) {
2553
- if (!running) return;
2554
- const aheadMs = leadMs - emulationLeadMs;
2555
- emulationLeadMs = leadMs;
2556
- if (aheadMs > 0) {
2557
- if (!processor.execute((aheadMs * clocksPerSecond) / 1000)) stop(true);
2558
- audioHandler.flushChipEvents();
2559
- } else {
2560
- last -= aheadMs;
2561
- }
2562
- }
2563
-
2564
- let wasPreviouslyRunning = false;
2565
-
2566
- function handleVisibilityChange() {
2567
- if (document.visibilityState === "hidden") {
2568
- wasPreviouslyRunning = running;
2569
- const keepRunningWhenHidden = processor.acia.motorOn || processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
2570
- if (running && !keepRunningWhenHidden) {
2571
- stop(false);
2572
- }
2573
- } else {
2574
- if (wasPreviouslyRunning) {
2575
- go();
2576
- }
2577
- }
2578
- }
2579
-
2580
- document.addEventListener("visibilitychange", handleVisibilityChange, false);
2581
-
2582
- function updateDebugButtons() {
2583
- debugPlay.disabled = running;
2584
- debugPause.disabled = !running;
2585
- }
2586
-
2587
- function go() {
2588
- audioHandler.unmute();
2589
- running = true;
2590
- keyboard.setRunning(true);
2591
- updateDebugButtons();
2592
- run();
2593
- }
2594
-
2595
- function stop(debug) {
2596
- running = false;
2597
- keyboard.setRunning(false);
2598
- processor.stop();
2599
- if (debug) dbgr.debug(processor.pc);
2600
- audioHandler.mute();
2601
- updateDebugButtons();
2602
- }
2603
-
2604
- /** Steps the drawing buffer grows in, as a multiple of the base canvas size. */
2605
- const CanvasScaleStep = 0.25;
2606
-
2607
- (function () {
2608
- const resizeCubMonitor = document.getElementById("cub-monitor");
2609
- const resizeCubMonitorPic = document.getElementById("cub-monitor-pic");
2610
- const borderReservedSize = parsedQuery.embed !== undefined ? 0 : 100;
2611
- const bottomReservedSize = parsedQuery.embed !== undefined ? 0 : 68;
2612
-
2613
- function resizeTv() {
2614
- // Get current display config (may change when display mode switches)
2615
- const displayConfig = displayModeFilter.getDisplayConfig();
2616
-
2617
- const imageOrigHeight = displayConfig.imageHeight;
2618
- const imageOrigWidth = displayConfig.imageWidth;
2619
- const canvasOrigLeft = displayConfig.canvasLeft;
2620
- const canvasOrigTop = displayConfig.canvasTop;
2621
- const visibleWidth = displayConfig.visibleWidth;
2622
- const visibleHeight = displayConfig.visibleHeight;
2623
-
2624
- const canvasNativeWidth = screenCanvas.getAttribute("width");
2625
- const canvasNativeHeight = screenCanvas.getAttribute("height");
2626
- const desiredAspectRatio = imageOrigWidth / imageOrigHeight;
2627
- const minWidth = imageOrigWidth / 4;
2628
- const minHeight = imageOrigHeight / 4;
2629
-
2630
- let navbarHeight = document.getElementById("header-bar")?.offsetHeight || 0;
2631
- let width = Math.max(minWidth, window.innerWidth - borderReservedSize * 2);
2632
- let height = Math.max(minHeight, window.innerHeight - navbarHeight - bottomReservedSize);
2633
- if (width / height <= desiredAspectRatio) {
2634
- height = width / desiredAspectRatio;
2635
- } else {
2636
- width = height * desiredAspectRatio;
2637
- }
2638
-
2639
- const containerScale = width / imageOrigWidth;
2640
- const scaledVisibleWidth = visibleWidth * containerScale;
2641
- const scaledVisibleHeight = visibleHeight * containerScale;
2642
-
2643
- const canvasAspect = canvasNativeWidth / canvasNativeHeight;
2644
- const visibleAspect = scaledVisibleWidth / scaledVisibleHeight;
2645
-
2646
- let finalCanvasWidth, finalCanvasHeight;
2647
- if (canvasAspect > visibleAspect) {
2648
- finalCanvasWidth = scaledVisibleWidth;
2649
- finalCanvasHeight = scaledVisibleWidth / canvasAspect;
2650
- } else {
2651
- finalCanvasHeight = scaledVisibleHeight;
2652
- finalCanvasWidth = scaledVisibleHeight * canvasAspect;
2653
- }
2654
-
2655
- resizeCubMonitor.style.height = height + "px";
2656
- resizeCubMonitor.style.width = width + "px";
2657
- resizeCubMonitorPic.style.height = height + "px";
2658
- resizeCubMonitorPic.style.width = width + "px";
2659
- // A mode that reconstructs detail wants to draw at the size it will be
2660
- // seen at, up to the limit it asks for. Drawing more than the display
2661
- // can show costs fragments and buys nothing, and for an expensive
2662
- // shader that is the difference between comfortable and not.
2663
- if (displayConfig.maxCanvasScale) {
2664
- const wanted = (finalCanvasWidth * (window.devicePixelRatio || 1)) / displayConfig.canvasWidth;
2665
- // Quantised, because resize fires continuously while a window is
2666
- // dragged and every distinct value reallocates the drawing buffer.
2667
- const quantised = Math.round(wanted / CanvasScaleStep) * CanvasScaleStep;
2668
- const scale = Math.min(displayConfig.maxCanvasScale, Math.max(1, quantised));
2669
- const backingWidth = Math.round(displayConfig.canvasWidth * scale);
2670
- if (screenCanvas.width !== backingWidth) {
2671
- screenCanvas.width = backingWidth;
2672
- screenCanvas.height = Math.round(displayConfig.canvasHeight * scale);
2673
- // Resizing threw the drawing buffer away.
2674
- video.paint();
2675
- }
2676
- }
2677
-
2678
- screenCanvas.style.width = finalCanvasWidth + "px";
2679
- screenCanvas.style.height = finalCanvasHeight + "px";
2680
- screenCanvas.style.left = canvasOrigLeft * containerScale + "px";
2681
- screenCanvas.style.top = canvasOrigTop * containerScale + "px";
2682
- }
2683
-
2684
- window.addEventListener("resize", resizeTv);
2685
- window.setTimeout(resizeTv, 1);
2686
- window.setTimeout(resizeTv, 500);
2687
- })();
2688
-
2689
- const $infoModal = new bootstrap.Modal(document.getElementById("info"));
2690
- const $ppTosModal = new bootstrap.Modal(document.getElementById("pp-tos"));
2691
-
2692
- if (Object.hasOwn(parsedQuery, "about")) {
2693
- $infoModal.show();
2694
- }
2695
- if (Object.hasOwn(parsedQuery, "pp-tos")) {
2696
- $ppTosModal.show();
2697
- }
458
+ // ------------------------------------------------------------------------
459
+ // The console surface the wiki documents, and the desktop app's hooks.
460
+ // ------------------------------------------------------------------------
2698
461
 
2699
462
  // Handy shortcuts. bench/profile stuff is delayed so that they can be
2700
463
  // safely run from the JS console in firefox.
2701
- window.benchmarkCpu = utils.debounce(benchmarkCpu, 1);
2702
- window.profileCpu = utils.debounce(profileCpu, 1);
2703
- window.benchmarkVideo = utils.debounce(benchmarkVideo, 1);
2704
- window.profileVideo = utils.debounce(profileVideo, 1);
2705
- window.go = go;
2706
- window.stop = stop;
464
+ window.benchmarkCpu = utils.debounce((numCycles) => loop.benchmarkCpu(numCycles), 1);
465
+ window.profileCpu = utils.debounce((arg) => loop.profileCpu(arg), 1);
466
+ window.benchmarkVideo = utils.debounce((numCycles) => loop.benchmarkVideo(numCycles), 1);
467
+ window.profileVideo = utils.debounce((arg) => loop.profileVideo(arg), 1);
468
+ window.go = () => loop.go();
469
+ window.stop = (debug) => loop.stop(debug);
2707
470
  window.soundChip = audioHandler.soundChip;
2708
471
  window.processor = processor;
2709
472
  window.video = video;
@@ -2733,28 +496,24 @@ window.m7dump = function () {
2733
496
 
2734
497
  // Hooks for electron.
2735
498
  electron({
2736
- loadDiscImage,
2737
- loadTapeImage,
499
+ loadDiscImage: media.loadDiscImage.bind(media),
500
+ loadTapeImage: media.loadTapeImage.bind(media),
2738
501
  processor,
2739
502
  config,
2740
503
  modals: {
2741
504
  show: (modalId, sthType) => {
2742
505
  if (modalId === "sth" && sthType) {
2743
- if (sthType === "discs") discSth.populate();
2744
- else if (sthType === "tapes") tapeSth.populate();
2745
- }
2746
- const modalEl = document.getElementById(modalId);
2747
- if (modalEl) {
2748
- const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
2749
- modal.show();
506
+ if (sthType === "discs") sthPicker.discs.populate();
507
+ else if (sthType === "tapes") sthPicker.tapes.populate();
2750
508
  }
509
+ modals.show(modalId);
2751
510
  },
2752
511
  },
2753
- loadStateFile: loadStateFromFile,
512
+ loadStateFile: snapshots.loadStateFromFile.bind(snapshots),
2754
513
  actions: {
2755
514
  "soft-reset": () => processor.reset(false),
2756
515
  "hard-reset": hardReset,
2757
- "save-state": () => document.getElementById("save-state").click(),
516
+ "save-state": () => snapshots.saveState(),
2758
517
  rewind: () => rewindUI.open(),
2759
518
  pause: pauseIntoDebugger,
2760
519
  resume: resumeFromDebugger,