jsbeeb 1.13.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.js CHANGED
@@ -19,6 +19,7 @@ import { GoogleDriveLoader } from "./google-drive.js";
19
19
  import * as tokeniser from "./basic-tokenise.js";
20
20
  import * as canvasLib from "./canvas.js";
21
21
  import { Config } from "./config.js";
22
+ import { tubeModelFor } from "./models.js";
22
23
  import { initialise as electron } from "./app/electron.js";
23
24
  import { AudioHandler } from "./web/audio-handler.js";
24
25
  import { Econet } from "./econet.js";
@@ -29,12 +30,21 @@ import { GamepadSource } from "./gamepad-source.js";
29
30
  import { MicrophoneInput } from "./microphone-input.js";
30
31
  import { SpeechOutput } from "./speech-output.js";
31
32
  import { MouseJoystickSource } from "./mouse-joystick-source.js";
33
+ import { calculateMouseCoordinates } from "./mouse-coordinates.js";
32
34
  import { getFilterForMode } from "./canvas.js";
33
- import { createSnapshot, restoreSnapshot, snapshotToJSON, snapshotFromJSON, isSameModel } from "./snapshot.js";
35
+ import {
36
+ createSnapshot,
37
+ restoreSnapshot,
38
+ snapshotToJSON,
39
+ snapshotFromJSON,
40
+ isSameModel,
41
+ hasCoProcessor,
42
+ } from "./snapshot.js";
34
43
  import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
35
44
  import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
36
45
  import { RewindBuffer } from "./rewind.js";
37
46
  import { RewindUI } from "./rewind-ui.js";
47
+ import { downloadBlob } from "./dom-utils.js";
38
48
  import {
39
49
  buildUrlFromParams,
40
50
  guessModelFromHostname,
@@ -42,7 +52,7 @@ import {
42
52
  parseMediaParams,
43
53
  parseQueryString,
44
54
  processAutobootParams,
45
- processKeyboardParams,
55
+ processInputParams,
46
56
  } from "./url-params.js";
47
57
 
48
58
  let processor;
@@ -129,7 +139,7 @@ const paramTypes = {
129
139
  audiofilterfreq: ParamTypes.FLOAT,
130
140
  audiofilterq: ParamTypes.FLOAT,
131
141
  cpuMultiplier: ParamTypes.FLOAT,
132
- tubeCpuMultiplier: ParamTypes.INT,
142
+ tubeCpuMultiplier: ParamTypes.FLOAT,
133
143
  microphoneChannel: ParamTypes.INT,
134
144
 
135
145
  // String parameters (these are the default but listed for clarity)
@@ -151,7 +161,7 @@ let keyLayout = window.localStorage.keyLayout || "physical";
151
161
 
152
162
  const BBC = utils.BBC;
153
163
  const keyCodes = utils.keyCodes;
154
- let cpuMultiplier = 1;
164
+ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
155
165
  let fastAsPossible = false;
156
166
  let fastTape = false;
157
167
  let noSeek;
@@ -167,9 +177,6 @@ const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage }
167
177
  if (queryDiscImage) discImage = queryDiscImage;
168
178
  if (querySecondDisc) secondDiscImage = querySecondDisc;
169
179
 
170
- // Process keyboard mappings
171
- parsedQuery = processKeyboardParams(parsedQuery, BBC, keyCodes, utils.userKeymap, gamepad);
172
-
173
180
  // Handle specific query parameters
174
181
  if (Array.isArray(parsedQuery.rom)) {
175
182
  parsedQuery.rom.forEach((romPath) => {
@@ -217,27 +224,8 @@ const userPort = {
217
224
  },
218
225
  };
219
226
 
220
- const emulationConfig = {
221
- keyLayout: keyLayout,
222
- coProcessor: parsedQuery.coProcessor,
223
- cpuMultiplier: cpuMultiplier,
224
- tubeCpuMultiplier: parsedQuery.tubeCpuMultiplier || 2,
225
- videoCyclesBatch: parsedQuery.videoCyclesBatch,
226
- extraRoms: extraRoms,
227
- userPort: userPort,
228
- printerPort: printerPort,
229
- getGamepads: function () {
230
- // Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
231
- return navigator.getGamepads ? navigator.getGamepads() : [];
232
- },
233
- debugFlags: {
234
- logFdcCommands: parsedQuery.logFdcCommands !== undefined,
235
- logFdcStateChanges: parsedQuery.logFdcStateChanges !== undefined,
236
- },
237
- };
238
-
239
227
  // Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
240
- // Must be created before Config so the onClose callback and setSpeechOutput() call can reference it.
228
+ // Must be created before Config so the onClose callback and the initial checkbox state can reference it.
241
229
  const speechOutput = new SpeechOutput();
242
230
  speechOutput.enabled = !!parsedQuery.speechOutput;
243
231
 
@@ -253,23 +241,6 @@ const config = new Config(
253
241
  },
254
242
  function onClose(changed) {
255
243
  parsedQuery = Object.assign(parsedQuery, changed);
256
- if (
257
- changed.model ||
258
- changed.coProcessor !== undefined ||
259
- changed.hasMusic5000 !== undefined ||
260
- changed.hasTeletextAdaptor !== undefined ||
261
- changed.hasEconet !== undefined
262
- ) {
263
- areYouSure(
264
- "Changing model requires a restart of the emulator. Restart now?",
265
- "Yes, restart now",
266
- "No, thanks",
267
- function () {
268
- updateUrl();
269
- window.location.reload();
270
- },
271
- );
272
- }
273
244
  if (changed.keyLayout) {
274
245
  window.localStorage.keyLayout = changed.keyLayout;
275
246
  emulationConfig.keyLayout = changed.keyLayout;
@@ -288,12 +259,22 @@ const config = new Config(
288
259
  if (changed.tubeCpuMultiplier !== undefined) {
289
260
  emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
290
261
  config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
291
- if (processor.tube && processor.tube.cpuMultiplier !== undefined) {
262
+ if (processor.hasTube) {
292
263
  processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
293
264
  }
294
265
  }
295
266
  updateUrl();
296
267
  },
268
+ function onRestartRequired() {
269
+ areYouSure(
270
+ "Your change is saved, but only takes effect when the emulator restarts. Restart now?",
271
+ "Restart now",
272
+ "Later",
273
+ function () {
274
+ window.location.reload();
275
+ },
276
+ );
277
+ },
297
278
  );
298
279
 
299
280
  // Perform mapping of legacy models to the new format
@@ -301,19 +282,54 @@ config.mapLegacyModels(parsedQuery);
301
282
 
302
283
  config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
303
284
  config.setKeyLayout(keyLayout);
304
- config.set65c02(parsedQuery.coProcessor);
305
- config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 2);
306
- config.setEconet(parsedQuery.hasEconet);
307
- config.setMusic5000(parsedQuery.hasMusic5000);
308
- config.setTeletext(parsedQuery.hasTeletextAdaptor);
285
+ config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
309
286
  config.setMicrophoneChannel(parsedQuery.microphoneChannel);
310
- config.setMouseJoystickEnabled(parsedQuery.mouseJoystickEnabled);
311
- config.setSpeechOutput(speechOutput.enabled);
287
+ config.setCheckboxes({
288
+ coProcessor: !!parsedQuery.coProcessor,
289
+ hasEconet: !!parsedQuery.hasEconet,
290
+ hasMusic5000: !!parsedQuery.hasMusic5000,
291
+ hasTeletextAdaptor: !!parsedQuery.hasTeletextAdaptor,
292
+ mouseJoystickEnabled: !!parsedQuery.mouseJoystickEnabled,
293
+ speechOutput: speechOutput.enabled,
294
+ });
312
295
  let displayMode = parsedQuery.displayMode || "rgb";
313
296
  config.setDisplayMode(displayMode);
314
297
 
315
298
  model = config.model;
316
299
 
300
+ // Must come after we know the model, to validate names against those of the hardware.
301
+ const keyMappingWarnings = processInputParams(
302
+ parsedQuery,
303
+ model.isAtom ? utils_atom.ATOM : BBC,
304
+ keyCodes,
305
+ utils.userKeymap,
306
+ gamepad,
307
+ );
308
+
309
+ // Depends on the config.setX calls above having applied the URL parameters.
310
+ const emulationConfig = {
311
+ keyLayout,
312
+ cpuMultiplier,
313
+ tubeCpuMultiplier: config.tubeCpuMultiplier,
314
+ videoCyclesBatch: parsedQuery.videoCyclesBatch,
315
+ tube: config.coProcessor ? tubeModelFor(config.model) : null,
316
+ hasMusic5000: config.hasMusic5000,
317
+ hasTeletextAdaptor: config.hasTeletextAdaptor,
318
+ // ROM order determines sideways bank allocation, and the fittings' ROMs claim banks
319
+ // before any the user asked for with ?rom=.
320
+ extraRoms: [...config.extraRoms, ...extraRoms],
321
+ userPort,
322
+ printerPort,
323
+ getGamepads: function () {
324
+ // Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
325
+ return navigator.getGamepads ? navigator.getGamepads() : [];
326
+ },
327
+ debugFlags: {
328
+ logFdcCommands: parsedQuery.logFdcCommands !== undefined,
329
+ logFdcStateChanges: parsedQuery.logFdcStateChanges !== undefined,
330
+ },
331
+ };
332
+
317
333
  function sbBind(div, url, onload) {
318
334
  const img = div.querySelector("img");
319
335
  img.style.display = "none";
@@ -335,11 +351,8 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
335
351
  div.style.bottom = -img.naturalHeight + "px";
336
352
  });
337
353
 
338
- if (parsedQuery.cpuMultiplier !== undefined) {
339
- cpuMultiplier = parsedQuery.cpuMultiplier;
340
- console.log("CPU multiplier set to " + cpuMultiplier);
341
- }
342
- const cpuSpeed = model.isAtom ? 1 * 1000 * 1000 : 2 * 1000 * 1000;
354
+ if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
355
+ const cpuSpeed = model.clockMhz * 1000 * 1000;
343
356
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
344
357
  const MaxCyclesPerFrame = clocksPerSecond / 10;
345
358
 
@@ -368,6 +381,10 @@ function showError(context, error) {
368
381
  errorDialogModal.show();
369
382
  }
370
383
 
384
+ if (keyMappingWarnings.length) {
385
+ showError("applying the key mappings in the URL", keyMappingWarnings.join(" "));
386
+ }
387
+
371
388
  function createCanvasForFilter(filterClass) {
372
389
  const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
373
390
 
@@ -465,18 +482,8 @@ function replaceOrAddExtension(name, newExt) {
465
482
  * @param {string} extension - The file extension to use
466
483
  */
467
484
  function downloadDriveData(data, name, extension) {
468
- const a = document.createElement("a");
469
- document.body.appendChild(a);
470
- a.style = "display: none";
471
-
472
- const fileName = replaceOrAddExtension(name, extension);
473
485
  const blob = new Blob([data], { type: "application/octet-stream" });
474
- const url = window.URL.createObjectURL(blob);
475
-
476
- a.href = url;
477
- a.download = fileName;
478
- a.click();
479
- window.URL.revokeObjectURL(url);
486
+ downloadBlob(blob, replaceOrAddExtension(name, extension));
480
487
  }
481
488
 
482
489
  async function loadHTMLFile(file) {
@@ -536,10 +543,8 @@ const cubMonitor = document.getElementById("cub-monitor");
536
543
  function onCubMouseEvent(evt) {
537
544
  audioHandler.tryResume();
538
545
  if (document.activeElement !== document.body) document.activeElement.blur();
539
- const cubRect = cubMonitor.getBoundingClientRect();
540
546
  const screenRect = screenCanvas.getBoundingClientRect();
541
- const x = (evt.offsetX - cubRect.left + screenRect.left) / screenCanvas.offsetWidth;
542
- const y = (evt.offsetY - cubRect.top + screenRect.top) / screenCanvas.offsetHeight;
547
+ const { x, y } = calculateMouseCoordinates(evt, screenRect);
543
548
 
544
549
  // Handle touchscreen
545
550
  if (processor.touchScreen) processor.touchScreen.onMouse(x, y, evt.buttons);
@@ -613,7 +618,7 @@ window.addEventListener("beforeunload", function (event) {
613
618
  }
614
619
  });
615
620
 
616
- if (model.hasEconet) {
621
+ if (config.hasEconet) {
617
622
  econet = new Econet(stationId);
618
623
  } else {
619
624
  document.getElementById("fsmenuitem").style.display = "none";
@@ -657,12 +662,14 @@ processor = new CpuClass(model, {
657
662
  soundChip: audioHandler.soundChip,
658
663
  ddNoise: audioHandler.ddNoise,
659
664
  relayNoise: audioHandler.relayNoise,
660
- music5000: model.hasMusic5000 ? audioHandler.music5000 : null,
665
+ music5000: config.hasMusic5000 ? audioHandler.music5000 : null,
661
666
  cmos,
662
667
  config: emulationConfig,
663
668
  econet,
664
669
  });
665
670
 
671
+ processor.teletextAdaptor?.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
672
+
666
673
  // Create input sources
667
674
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
668
675
  // Create MicrophoneInput but don't enable by default
@@ -677,18 +684,16 @@ const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
677
684
  /**
678
685
  * Attach an RS-423 composite handler to the ACIA that combines the touchscreen
679
686
  * (which sends position data to the BBC) with the speech output (which speaks
680
- * text the BBC sends out). Call this once after processor.initialise() and
681
- * again whenever speechOutput.enabled changes.
687
+ * text the BBC sends out).
682
688
  */
683
689
  function setupRs423Handler() {
684
- const touchScreen = processor.touchScreen;
685
690
  processor.acia.setRs423Handler({
686
691
  onTransmit(val) {
687
- touchScreen.onTransmit(val);
692
+ processor.touchScreen.onTransmit(val);
688
693
  speechOutput.onTransmit(val);
689
694
  },
690
695
  tryReceive(rts) {
691
- return touchScreen.tryReceive(rts);
696
+ return processor.touchScreen.tryReceive(rts);
692
697
  },
693
698
  });
694
699
  }
@@ -1441,7 +1446,12 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1441
1446
  let data;
1442
1447
  if (document.querySelector("#google-drive .create-from-existing").checked) {
1443
1448
  const discType = disc.guessDiscTypeFromName(name);
1444
- data = discType.saver(processor.fdc.drives[0].disc);
1449
+ try {
1450
+ data = discType.saver(processor.fdc.drives[0].disc);
1451
+ } catch (e) {
1452
+ loadingFinished(`Create failed: ${e.message}`);
1453
+ return;
1454
+ }
1445
1455
  name = replaceOrAddExtension(name, discType.extension);
1446
1456
  console.log(`Saving existing disc: ${name}`);
1447
1457
  } else {
@@ -1470,11 +1480,15 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1470
1480
 
1471
1481
  document.getElementById("download-drive-link").addEventListener("click", function () {
1472
1482
  const disc = processor.fdc.drives[0].disc;
1473
- const data = toSsdOrDsd(disc);
1474
- const name = disc.name;
1475
- const extension = disc.isDoubleSided ? ".dsd" : ".ssd";
1476
-
1477
- downloadDriveData(data, name, extension);
1483
+ const save = (options) =>
1484
+ downloadDriveData(toSsdOrDsd(disc, options), disc.name, disc.isDoubleSided ? ".dsd" : ".ssd");
1485
+ try {
1486
+ save();
1487
+ } catch (e) {
1488
+ areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
1489
+ save({ force: true }),
1490
+ );
1491
+ }
1478
1492
  });
1479
1493
 
1480
1494
  document.getElementById("download-drive-hfe-link").addEventListener("click", function () {
@@ -1535,13 +1549,8 @@ document.getElementById("save-state").addEventListener("click", async function (
1535
1549
  const snapshot = createSnapshot(processor, model, Object.keys(media).length > 0 ? media : undefined);
1536
1550
  const json = snapshotToJSON(snapshot);
1537
1551
  const blob = await compressBlob(new Blob([json]));
1538
- const url = URL.createObjectURL(blob);
1539
1552
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1540
- const a = document.createElement("a");
1541
- a.href = url;
1542
- a.download = `jsbeeb-${model.name}-${timestamp}.json.gz`;
1543
- a.click();
1544
- URL.revokeObjectURL(url);
1553
+ downloadBlob(blob, `jsbeeb-${model.name}-${timestamp}.json.gz`);
1545
1554
  } catch (e) {
1546
1555
  showError("saving state", e);
1547
1556
  }
@@ -1570,10 +1579,10 @@ async function loadStateFromFile(file, preReadBuffer) {
1570
1579
  }
1571
1580
  snapshot = snapshotFromJSON(text);
1572
1581
  }
1573
- if (!isSameModel(snapshot.model, model.name)) {
1574
- // Model mismatch: stash state and reload with correct model
1582
+ if (!isSameModel(snapshot.model, model.name) || hasCoProcessor(snapshot) !== processor.hasTube) {
1583
+ // Model or co-processor mismatch: stash state and reload with a matching machine
1575
1584
  sessionStorage.setItem("jsbeeb-pending-state", snapshotToJSON(snapshot));
1576
- const newQuery = { ...parsedQuery, model: snapshot.model };
1585
+ const newQuery = { ...parsedQuery, model: snapshot.model, coProcessor: hasCoProcessor(snapshot) };
1577
1586
  const baseUrl = window.location.origin + window.location.pathname;
1578
1587
  window.location.href = buildUrlFromParams(baseUrl, newQuery, paramTypes);
1579
1588
  return;
@@ -1691,7 +1700,7 @@ syncLights = function () {
1691
1700
  drive0.update(processor.fdc.motorOn[0]);
1692
1701
  drive1.update(processor.fdc.motorOn[1]);
1693
1702
  cassette.update(processor.acia.motorOn);
1694
- if (model.hasEconet) {
1703
+ if (processor.econet) {
1695
1704
  network.update(processor.econet.activityLight());
1696
1705
  }
1697
1706
  }
@@ -1849,14 +1858,22 @@ const aysEl = document.getElementById("are-you-sure");
1849
1858
  const aysModal = new bootstrap.Modal(aysEl);
1850
1859
 
1851
1860
  function areYouSure(message, yesText, noText, yesFunc) {
1861
+ const yesButton = aysEl.querySelector(".ays-yes");
1852
1862
  aysEl.querySelector(".context").textContent = message;
1853
- aysEl.querySelector(".ays-yes").textContent = yesText;
1854
1863
  aysEl.querySelector(".ays-no").textContent = noText;
1855
- aysEl.querySelector(".ays-yes").addEventListener(
1856
- "click",
1857
- function () {
1858
- aysModal.hide();
1859
- yesFunc();
1864
+ yesButton.textContent = yesText;
1865
+ let confirmed = false;
1866
+ const onYes = () => {
1867
+ confirmed = true;
1868
+ aysModal.hide();
1869
+ };
1870
+ yesButton.addEventListener("click", onYes, { once: true });
1871
+ // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
1872
+ aysEl.addEventListener(
1873
+ "hidden.bs.modal",
1874
+ () => {
1875
+ yesButton.removeEventListener("click", onYes);
1876
+ if (confirmed) yesFunc();
1860
1877
  },
1861
1878
  { once: true },
1862
1879
  );
package/src/models.js CHANGED
@@ -10,11 +10,21 @@ const CpuModel = Object.freeze({
10
10
  CMOS65C12: 2,
11
11
  });
12
12
 
13
+ /**
14
+ * Describes what a machine *is*: which CPU, which ROMs, which disc controller.
15
+ * Anything a user can turn on and off for a session (second processor, Econet,
16
+ * Music 5000, teletext adaptor) belongs in the emulation config passed to Cpu6502.
17
+ *
18
+ * Instances are shared process-wide via `allModels` and frozen, so they can be handed
19
+ * to any number of machines without one session's settings leaking into the next.
20
+ */
13
21
  class Model {
14
- constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, tube, cmosOverride, banks } = {}) {
22
+ constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, cmosOverride, banks, clockMhz } = {}) {
23
+ if (!(clockMhz > 0)) throw new Error(`Model ${name} has no clock speed`);
15
24
  this.name = name;
16
25
  this.synonyms = synonyms;
17
26
  this.os = os;
27
+ this.clockMhz = clockMhz;
18
28
  this.banks = banks;
19
29
  this._cpuModel = cpuModel;
20
30
  this.isMaster = isMaster;
@@ -22,10 +32,7 @@ class Model {
22
32
  this.Fdc = fdc;
23
33
  this.swram = swram;
24
34
  this.isTest = false;
25
- this.tube = tube;
26
35
  this.cmosOverride = cmosOverride;
27
- this.hasEconet = false;
28
- this.hasMusic5000 = false;
29
36
  }
30
37
 
31
38
  get nmos() {
@@ -67,6 +74,7 @@ function atomModel({ name, synonyms, os, banks }) {
67
74
  os,
68
75
  cpuModel: CpuModel.MOS6502,
69
76
  isMaster: false,
77
+ clockMhz: 1,
70
78
  isAtom: true,
71
79
  swram: beebSwram,
72
80
  fdc: NoiseAwareIntelFdc,
@@ -119,6 +127,7 @@ export const allModels = [
119
127
  os: ["os.rom", "BASIC.ROM", "b/DFS-1.2.rom"],
120
128
  cpuModel: CpuModel.MOS6502,
121
129
  isMaster: false,
130
+ clockMhz: 2,
122
131
  swram: beebSwram,
123
132
  fdc: NoiseAwareIntelFdc,
124
133
  }),
@@ -128,6 +137,7 @@ export const allModels = [
128
137
  os: ["os.rom", "BASIC.ROM", "b/DFS-0.9.rom"],
129
138
  cpuModel: CpuModel.MOS6502,
130
139
  isMaster: false,
140
+ clockMhz: 2,
131
141
  swram: beebSwram,
132
142
  fdc: NoiseAwareIntelFdc,
133
143
  }),
@@ -137,6 +147,7 @@ export const allModels = [
137
147
  os: ["os.rom", "BASIC.ROM", "b1770/dfs1770.rom", "b1770/zADFS.ROM"],
138
148
  cpuModel: CpuModel.MOS6502,
139
149
  isMaster: false,
150
+ clockMhz: 2,
140
151
  swram: beebSwram,
141
152
  fdc: NoiseAwareWdFdc,
142
153
  }),
@@ -147,6 +158,7 @@ export const allModels = [
147
158
  os: ["os.rom", "BASIC.ROM", "b1770/zADFS.ROM", "b1770/dfs1770.rom"],
148
159
  cpuModel: CpuModel.MOS6502,
149
160
  isMaster: false,
161
+ clockMhz: 2,
150
162
  swram: beebSwram,
151
163
  fdc: NoiseAwareWdFdc,
152
164
  }),
@@ -156,6 +168,7 @@ export const allModels = [
156
168
  os: ["master/mos3.20"],
157
169
  cpuModel: CpuModel.CMOS65C12,
158
170
  isMaster: true,
171
+ clockMhz: 2,
159
172
  swram: masterSwram,
160
173
  fdc: NoiseAwareWdFdc,
161
174
  cmosOverride: pickDfs,
@@ -166,6 +179,7 @@ export const allModels = [
166
179
  os: ["master/mos3.20"],
167
180
  cpuModel: CpuModel.CMOS65C12,
168
181
  isMaster: true,
182
+ clockMhz: 2,
169
183
  swram: masterSwram,
170
184
  fdc: NoiseAwareWdFdc,
171
185
  cmosOverride: pickAdfs,
@@ -176,6 +190,7 @@ export const allModels = [
176
190
  os: ["master/mos3.20"],
177
191
  cpuModel: CpuModel.CMOS65C12,
178
192
  isMaster: true,
193
+ clockMhz: 2,
179
194
  swram: masterSwram,
180
195
  fdc: NoiseAwareWdFdc,
181
196
  cmosOverride: pickAnfs,
@@ -205,13 +220,24 @@ export const allModels = [
205
220
  synonyms: ["Atom-DOS"],
206
221
  os: ["atom/Atom_Kernel.rom", "atom/Atom_DOS.rom", "atom/Atom_FloatingPoint.rom", "atom/Atom_Basic.rom"],
207
222
  }),
208
- // Although this can not be explicitly selected as a model, it is required by the configuration builder later
223
+ // Neither can be selected as a model: they are fitted to one, by the configuration builder later.
209
224
  new Model({
210
225
  name: "Tube65C02",
211
226
  synonyms: [],
212
227
  os: ["tube/6502Tube.rom"],
228
+ // TODO(#746): the external second processor was an NMOS 6502A.
213
229
  cpuModel: CpuModel.CMOS65C02,
214
230
  isMaster: false,
231
+ clockMhz: 3,
232
+ }),
233
+ new Model({
234
+ name: "Tube65C102",
235
+ synonyms: [],
236
+ os: ["tube/65C102Tube.rom"],
237
+ // TODO(#746): Acorn's 65C102 has no Rockwell bit instructions.
238
+ cpuModel: CpuModel.CMOS65C02,
239
+ isMaster: false,
240
+ clockMhz: 4,
215
241
  }),
216
242
  ];
217
243
 
@@ -231,6 +257,7 @@ export const TEST_6502 = new Model({
231
257
  name: "TEST",
232
258
  synonyms: ["TEST"],
233
259
  os: [],
260
+ clockMhz: 2,
234
261
  cpuModel: CpuModel.MOS6502,
235
262
  isMaster: false,
236
263
  swram: beebSwram,
@@ -241,6 +268,7 @@ export const TEST_65C02 = new Model({
241
268
  name: "TEST",
242
269
  synonyms: ["TEST"],
243
270
  os: [],
271
+ clockMhz: 2,
244
272
  cpuModel: CpuModel.CMOS65C02,
245
273
  isMaster: false,
246
274
  swram: masterSwram,
@@ -251,6 +279,7 @@ export const TEST_65C12 = new Model({
251
279
  name: "TEST",
252
280
  synonyms: ["TEST"],
253
281
  os: [],
282
+ clockMhz: 2,
254
283
  cpuModel: CpuModel.CMOS65C12,
255
284
  isMaster: false,
256
285
  swram: masterSwram,
@@ -260,6 +289,7 @@ TEST_65C12.isTest = true;
260
289
 
261
290
  export const basicOnly = new Model({
262
291
  name: "Basic only",
292
+ clockMhz: 2,
263
293
  synonyms: ["Basic only"],
264
294
  os: ["master/mos3.20"],
265
295
  cpuModel: CpuModel.CMOS65C12,
@@ -267,3 +297,25 @@ export const basicOnly = new Model({
267
297
  swram: masterSwram,
268
298
  fdc: NoiseAwareWdFdc,
269
299
  });
300
+
301
+ /**
302
+ * The second processors jsbeeb emulates: the external 3MHz box, and the Master Turbo's
303
+ * internal 4MHz board. Machine-building code passes one of these as the emulation config's
304
+ * `tube`, so that 6502.js needn't import this module and close an import cycle via the FDC
305
+ * modules.
306
+ */
307
+ export const TubeModel = findModel("Tube65C02");
308
+ export const TurboTubeModel = findModel("Tube65C102");
309
+
310
+ /** @returns {Model} the second processor sold for this machine: the Turbo board for a Master. */
311
+ export function tubeModelFor(model) {
312
+ return model.isMaster ? TurboTubeModel : TubeModel;
313
+ }
314
+
315
+ // After the isTest assignments above, so those still apply.
316
+ for (const model of [...allModels, TEST_6502, TEST_65C02, TEST_65C12, basicOnly]) {
317
+ Object.freeze(model.os);
318
+ Object.freeze(model.synonyms);
319
+ Object.freeze(model);
320
+ }
321
+ Object.freeze(allModels);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Calculate normalized mouse coordinates relative to the screen canvas.
3
+ *
4
+ * Uses clientX/clientY against the canvas bounding rect so that coordinates
5
+ * are correct even when the canvas is inset within a monitor image (e.g.
6
+ * display filters with non-zero canvasLeft/canvasTop).
7
+ *
8
+ * @param {MouseEvent} evt - The mouse event
9
+ * @param {DOMRect} screenRect - Bounding client rect of the screen canvas
10
+ * @returns {{x: number, y: number}} Normalized coordinates in [0, 1]
11
+ */
12
+ export function calculateMouseCoordinates(evt, screenRect) {
13
+ const x = (evt.clientX - screenRect.left) / screenRect.width;
14
+ const y = (evt.clientY - screenRect.top) / screenRect.height;
15
+ return { x, y };
16
+ }
package/src/serial.js CHANGED
@@ -20,6 +20,7 @@ export class Serial {
20
20
  this.transmitRate = val & 0x07;
21
21
  this.receiveRate = (val >>> 3) & 0x07;
22
22
  this.acia.setSerialReceive(table[this.receiveRate]);
23
+ this.acia.setSerialTransmit(table[this.transmitRate]);
23
24
  this.acia.setMotor(!!(val & 0x80));
24
25
  this.acia.selectRs423(!!(val & 0x40));
25
26
  }
@@ -25,7 +25,7 @@ export const DefaultAcia = {
25
25
  tapeDcdLineLevel: false,
26
26
  hadDcdHigh: false,
27
27
  serialReceiveRate: 19200,
28
- serialReceiveCyclesPerByte: 0,
28
+ serialTransmitRate: 19200,
29
29
  txCompleteTaskOffset: null,
30
30
  runTapeTaskOffset: null,
31
31
  runRs423TaskOffset: null,
@@ -166,12 +166,25 @@ export function buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, c
166
166
  * @param {object} uservia - jsbeeb user VIA state
167
167
  * @param {object} video - jsbeeb video state (from buildVideoState)
168
168
  * @param {object} soundChip - jsbeeb sound chip state
169
+ * @param {object|null} [tube] - jsbeeb tube state, or null if no co-processor was fitted
169
170
  */
170
- export function buildSnapshot(importedFrom, modelName, cpuState, ram, roms, sysvia, uservia, video, soundChip) {
171
+ export function buildSnapshot(
172
+ importedFrom,
173
+ modelName,
174
+ cpuState,
175
+ ram,
176
+ roms,
177
+ sysvia,
178
+ uservia,
179
+ video,
180
+ soundChip,
181
+ tube = null,
182
+ ) {
171
183
  return {
172
184
  format: "jsbeeb-snapshot",
173
- version: 2,
185
+ version: 3,
174
186
  model: modelName,
187
+ coProcessor: !!tube,
175
188
  timestamp: new Date().toISOString(),
176
189
  importedFrom,
177
190
  state: {
@@ -203,6 +216,7 @@ export function buildSnapshot(importedFrom, modelName, cpuState, ram, roms, sysv
203
216
  soundChip,
204
217
  acia: { ...DefaultAcia },
205
218
  adc: { ...DefaultAdc },
219
+ ...(tube ? { tube } : {}),
206
220
  },
207
221
  };
208
222
  }