jsbeeb 1.13.1 → 1.14.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/config.js CHANGED
@@ -2,30 +2,85 @@
2
2
  import { allModels, findModel } from "./models.js";
3
3
  import { getFilterForMode } from "./canvas.js";
4
4
 
5
+ /**
6
+ * The sideways ROMs the optional fittings need, in the order they claim banks.
7
+ *
8
+ * @param {{model: object, hasEconet: boolean, hasMusic5000: boolean, hasTeletextAdaptor: boolean}} settings
9
+ * @returns {string[]}
10
+ */
11
+ export function fittedRoms({ model, hasEconet, hasMusic5000, hasTeletextAdaptor }) {
12
+ return [
13
+ ...(hasEconet && model.isMaster ? ["master/anfs-4.25.rom"] : []),
14
+ ...(hasMusic5000 ? ["ample.rom"] : []),
15
+ ...(hasTeletextAdaptor ? ["ats-3.0.rom"] : []),
16
+ ];
17
+ }
18
+
19
+ /** The settings the dialog presents as checkboxes. `enables` names a control only usable while ticked. */
20
+ export const CheckboxSettings = [
21
+ { id: "65c02", field: "coProcessor", restartRequired: true, enables: "tubeCpuMultiplier" },
22
+ { id: "hasTeletextAdaptor", field: "hasTeletextAdaptor", restartRequired: true },
23
+ { id: "hasEconet", field: "hasEconet", restartRequired: true },
24
+ { id: "hasMusic5000", field: "hasMusic5000", restartRequired: true },
25
+ { id: "mouseJoystickEnabled", field: "mouseJoystickEnabled" },
26
+ { id: "speechOutput", field: "speechOutput" },
27
+ ];
28
+
29
+ /** The model is not a checkbox, but changing it needs a restart just the same. */
30
+ const RestartRequiredFields = [
31
+ "model",
32
+ ...CheckboxSettings.filter((setting) => setting.restartRequired).map((setting) => setting.field),
33
+ ];
34
+
35
+ /** @returns {boolean} whether any of the changed settings only take effect on a freshly built machine. */
36
+ export function needsRestart(changed) {
37
+ return RestartRequiredFields.some((field) => field in changed);
38
+ }
39
+
40
+ /** @returns {boolean} whether the saved settings differ from those the running machine was built with. */
41
+ export function restartPending(settings, running) {
42
+ return RestartRequiredFields.some((field) => settings[field] !== running[field]);
43
+ }
44
+
5
45
  export class Config extends EventTarget {
6
- constructor(onChange, onClose) {
46
+ /**
47
+ * @param {function(object)} onChange called as soon as a setting the emulator can follow live changes
48
+ * @param {function(object)} onClose called with the settings to apply and persist
49
+ * @param {function()} onRestartRequired called after the settings have been saved, when some of them
50
+ * will only take effect once the machine is rebuilt
51
+ */
52
+ constructor(onChange, onClose, onRestartRequired) {
7
53
  super();
8
54
  this.onChange = onChange;
9
55
  this.onClose = onClose;
56
+ this.onRestartRequired = onRestartRequired;
10
57
  this.changed = {};
11
58
  this.model = null;
12
- this.coProcessor = null;
59
+ for (const { field } of CheckboxSettings) this[field] = false;
60
+ this.runningSettings = null;
13
61
  const configuration = document.getElementById("configuration");
14
62
  configuration.addEventListener("show.bs.modal", () => {
15
63
  this.changed = {};
64
+ // The startup settings are pushed in after construction, so what the running machine was
65
+ // built with is only knowable from the first time the dialog is opened.
66
+ if (!this.runningSettings) this.runningSettings = this.proposedSettings();
16
67
  this.setDropdownText(this.model.name);
17
- this.set65c02(this.model.tube);
18
68
  this.setTubeCpuMultiplier(this.tubeCpuMultiplier);
19
- this.setTeletext(this.model.hasTeletextAdaptor);
20
- this.setMusic5000(this.model.hasMusic5000);
21
- this.setEconet(this.model.hasEconet);
69
+ this.setCheckboxes(this);
70
+ this.showRestartPending();
22
71
  });
23
72
 
24
73
  configuration.addEventListener("hide.bs.modal", () => {
25
- this.onClose(this.changed);
26
- if (Object.keys(this.changed).length > 0) {
27
- this.dispatchEvent(new CustomEvent("settings-changed", { detail: this.changed }));
28
- }
74
+ const changed = this.changed;
75
+ // Not setModel: that also renames the machine in the title bar, which has not changed yet.
76
+ if (changed.model !== undefined) this.model = findModel(changed.model);
77
+ this.setCheckboxes(changed);
78
+ this.onClose(changed);
79
+ if (Object.keys(changed).length === 0) return;
80
+ this.dispatchEvent(new CustomEvent("settings-changed", { detail: changed }));
81
+ // changed records which controls were touched, so a value in it can be what is already running.
82
+ if (needsRestart(changed) && restartPending(this.proposedSettings(), this.runningSettings))
83
+ this.onRestartRequired();
29
84
  });
30
85
 
31
86
  const modelMenu = document.querySelector(".model-menu");
@@ -45,12 +100,17 @@ export class Config extends EventTarget {
45
100
  if (!link) return;
46
101
  this.changed.model = link.dataset.target;
47
102
  this.setDropdownText(link.textContent);
103
+ this.showRestartPending();
48
104
  });
49
105
 
50
- document.getElementById("65c02").addEventListener("click", () => {
51
- this.changed.coProcessor = document.getElementById("65c02").checked;
52
- document.getElementById("tubeCpuMultiplier").disabled = !document.getElementById("65c02").checked;
53
- });
106
+ for (const { id, field, enables } of CheckboxSettings) {
107
+ document.getElementById(id).addEventListener("click", () => {
108
+ const checked = document.getElementById(id).checked;
109
+ this.changed[field] = checked;
110
+ if (enables) document.getElementById(enables).disabled = !checked;
111
+ this.showRestartPending();
112
+ });
113
+ }
54
114
 
55
115
  document.getElementById("tubeCpuMultiplier").addEventListener("input", () => {
56
116
  const val = parseInt(document.getElementById("tubeCpuMultiplier").value, 10);
@@ -58,18 +118,6 @@ export class Config extends EventTarget {
58
118
  this.changed.tubeCpuMultiplier = val;
59
119
  });
60
120
 
61
- document.getElementById("hasTeletextAdaptor").addEventListener("click", () => {
62
- this.changed.hasTeletextAdaptor = document.getElementById("hasTeletextAdaptor").checked;
63
- });
64
-
65
- document.getElementById("hasEconet").addEventListener("click", () => {
66
- this.changed.hasEconet = document.getElementById("hasEconet").checked;
67
- });
68
-
69
- document.getElementById("hasMusic5000").addEventListener("click", () => {
70
- this.changed.hasMusic5000 = document.getElementById("hasMusic5000").checked;
71
- });
72
-
73
121
  for (const link of document.querySelectorAll(".keyboard-menu a")) {
74
122
  link.addEventListener("click", (e) => {
75
123
  const keyLayout = e.target.dataset.target;
@@ -87,14 +135,6 @@ export class Config extends EventTarget {
87
135
  });
88
136
  }
89
137
 
90
- document.getElementById("mouseJoystickEnabled").addEventListener("click", () => {
91
- this.changed.mouseJoystickEnabled = document.getElementById("mouseJoystickEnabled").checked;
92
- });
93
-
94
- document.getElementById("speechOutput").addEventListener("click", () => {
95
- this.changed.speechOutput = document.getElementById("speechOutput").checked;
96
- });
97
-
98
138
  for (const option of document.querySelectorAll(".display-mode-option")) {
99
139
  option.addEventListener("click", (e) => {
100
140
  const mode = e.target.dataset.mode;
@@ -105,17 +145,34 @@ export class Config extends EventTarget {
105
145
  }
106
146
  }
107
147
 
108
- setMicrophoneChannel(channel) {
109
- const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
110
- for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
148
+ /**
149
+ * The restart-required settings as they would be saved if the dialog were closed now, in the form the
150
+ * menu and the URL use: the model by synonym rather than resolved, the fittings as booleans.
151
+ */
152
+ proposedSettings() {
153
+ const saved = (field) => (field === "model" ? this.model.synonyms[0] : this[field]);
154
+ return Object.fromEntries(RestartRequiredFields.map((field) => [field, this.changed[field] ?? saved(field)]));
111
155
  }
112
156
 
113
- setMouseJoystickEnabled(enabled) {
114
- document.getElementById("mouseJoystickEnabled").checked = !!enabled;
157
+ showRestartPending() {
158
+ const pending = restartPending(this.proposedSettings(), this.runningSettings);
159
+ document.getElementById("restart-pending").classList.toggle("d-none", !pending);
160
+ }
161
+
162
+ /** Ticks the boxes named in `values` and adopts them, leaving any the object does not mention alone. */
163
+ setCheckboxes(values) {
164
+ for (const { id, field, enables } of CheckboxSettings) {
165
+ if (values[field] === undefined) continue;
166
+ const checked = !!values[field];
167
+ document.getElementById(id).checked = checked;
168
+ this[field] = checked;
169
+ if (enables) document.getElementById(enables).disabled = !checked;
170
+ }
115
171
  }
116
172
 
117
- setSpeechOutput(enabled) {
118
- document.getElementById("speechOutput").checked = !!enabled;
173
+ setMicrophoneChannel(channel) {
174
+ const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
175
+ for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
119
176
  }
120
177
 
121
178
  setDisplayMode(mode) {
@@ -133,57 +190,19 @@ export class Config extends EventTarget {
133
190
  for (const el of document.querySelectorAll(".keyboard-layout")) el.textContent = text;
134
191
  }
135
192
 
136
- set65c02(enabled) {
137
- enabled = !!enabled;
138
- document.getElementById("65c02").checked = enabled;
139
- this.model.tube = enabled ? findModel("Tube65c02") : null;
140
- document.getElementById("tubeCpuMultiplier").disabled = !enabled;
141
- }
142
-
143
193
  setTubeCpuMultiplier(value) {
144
194
  this.tubeCpuMultiplier = value;
145
195
  document.getElementById("tubeCpuMultiplier").value = value;
146
196
  document.getElementById("tubeCpuMultiplierValue").textContent = value;
147
197
  }
148
198
 
149
- setEconet(enabled) {
150
- enabled = !!enabled;
151
- document.getElementById("hasEconet").checked = enabled;
152
- this.model.hasEconet = enabled;
153
-
154
- if (enabled && this.model.isMaster) {
155
- this.addRemoveROM("master/anfs-4.25.rom", true);
156
- }
157
- }
158
-
159
- setMusic5000(enabled) {
160
- enabled = !!enabled;
161
- document.getElementById("hasMusic5000").checked = enabled;
162
- this.model.hasMusic5000 = enabled;
163
- this.addRemoveROM("ample.rom", enabled);
164
- }
165
-
166
- setTeletext(enabled) {
167
- enabled = !!enabled;
168
- document.getElementById("hasTeletextAdaptor").checked = enabled;
169
- this.model.hasTeletextAdaptor = enabled;
170
- this.addRemoveROM("ats-3.0.rom", enabled);
171
- }
172
-
173
199
  setDropdownText(modelName) {
174
200
  const el = document.querySelector("#bbc-model-dropdown .bbc-model");
175
201
  if (el) el.textContent = modelName;
176
202
  }
177
203
 
178
- addRemoveROM(romName, required) {
179
- if (required && !this.model.os.includes(romName)) {
180
- this.model.os.push(romName);
181
- } else {
182
- let pos = this.model.os.indexOf(romName);
183
- if (pos !== -1) {
184
- this.model.os.splice(pos, 1);
185
- }
186
- }
204
+ get extraRoms() {
205
+ return fittedRoms(this);
187
206
  }
188
207
 
189
208
  mapLegacyModels(parsedQuery) {
package/src/fake6502.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { FakeVideo } from "./video.js";
5
5
  import { FakeSoundChip } from "./soundchip.js";
6
- import { findModel, TEST_6502, TEST_65C02, TEST_65C12 } from "./models.js";
6
+ import { TEST_6502, TEST_65C02, TEST_65C12, TubeModel } from "./models.js";
7
7
  import { FakeDdNoise } from "./ddnoise.js";
8
8
  import { FakeRelayNoise } from "./relaynoise.js";
9
9
  import { Cpu6502, AtomCpu6502 } from "./6502.js";
@@ -19,7 +19,6 @@ const dbgr = {
19
19
  export function fake6502(model, opts) {
20
20
  opts = opts || {};
21
21
  model = model || TEST_6502;
22
- if (opts.tube) model.tube = findModel("Tube65c02");
23
22
  const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
24
23
  return new CpuClass(model, {
25
24
  dbgr,
@@ -30,6 +29,12 @@ export function fake6502(model, opts) {
30
29
  music5000: new FakeMusic5000(),
31
30
  cmos: new Cmos(),
32
31
  cycleAccurate: opts.cycleAccurate,
32
+ config: {
33
+ tube: opts.tube ? TubeModel : null,
34
+ tubeCpuMultiplier: opts.tubeCpuMultiplier,
35
+ cpuMultiplier: opts.cpuMultiplier,
36
+ hasTeletextAdaptor: opts.hasTeletextAdaptor,
37
+ },
33
38
  });
34
39
  }
35
40
 
package/src/keyboard.js CHANGED
@@ -341,8 +341,9 @@ export class Keyboard extends EventTarget {
341
341
  if (this.isPasting) this.cancelPaste();
342
342
 
343
343
  this.keyInterface.disableKeyboard();
344
- this._pasteClocksPerMs =
345
- Math.floor(this.processor.cpuMultiplier * this.processor.peripheralCyclesPerSecond) / 1000;
344
+ // The paste task lives on the processor's scheduler, which is polled with peripheral
345
+ // cycles, so paste delays stay in real time whatever the CPU multiplier is.
346
+ this._pasteClocksPerMs = this.processor.peripheralCyclesPerSecond / 1000;
346
347
 
347
348
  if (checkCapsAndShiftLocks) {
348
349
  let toggleKey = null;
@@ -30,6 +30,9 @@ export class MachineSession {
30
30
  * @param {string} modelName - e.g. "B-DFS1.2", "Master"
31
31
  * @param {Object} [opts]
32
32
  * @param {string} [opts.discImage] - path to an .ssd or .dsd disc image to load on boot
33
+ * @param {boolean} [opts.tube] - attach a 65C02 second processor (Tube co-processor)
34
+ * @param {number} [opts.cpuMultiplier] - run the CPU this many times faster than the peripherals
35
+ * @param {boolean} [opts.hasTeletextAdaptor] - fit the Acorn teletext adaptor
33
36
  */
34
37
  constructor(modelName = "B-DFS1.2", opts = {}) {
35
38
  this.modelName = modelName;
@@ -65,8 +68,14 @@ export class MachineSession {
65
68
  // toneGenerator); FakeSoundChip provides compatible no-op stubs for headless mode.
66
69
  this._soundChip = modelObj.isAtom ? new FakeSoundChip() : new InstrumentedSoundChip();
67
70
 
68
- // TestMachine forwards opts.video and opts.soundChip to fake6502
69
- this._machine = new TestMachine(modelName, { video: this._video, soundChip: this._soundChip });
71
+ // TestMachine forwards these to fake6502
72
+ this._machine = new TestMachine(modelName, {
73
+ video: this._video,
74
+ soundChip: this._soundChip,
75
+ tube: opts.tube,
76
+ cpuMultiplier: opts.cpuMultiplier,
77
+ hasTeletextAdaptor: opts.hasTeletextAdaptor,
78
+ });
70
79
 
71
80
  // Accumulated VDU text output — drained by callers
72
81
  this._pendingOutput = [];
package/src/main.js CHANGED
@@ -7,7 +7,7 @@ import "./jsbeeb.css";
7
7
  import * as utils from "./utils.js";
8
8
  import { FakeVideo, Video } from "./video.js";
9
9
  import { Debugger } from "./web/debug.js";
10
- import { Cpu6502, AtomCpu6502 } from "./6502.js";
10
+ import { Cpu6502, AtomCpu6502, DefaultTubeCpuMultiplier } from "./6502.js";
11
11
  import * as utils_atom from "./utils_atom.js";
12
12
  import { LoadSD } from "./mmc.js";
13
13
  import { Cmos } from "./cmos.js";
@@ -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 { TubeModel } 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,8 +30,16 @@ 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";
@@ -151,7 +160,7 @@ let keyLayout = window.localStorage.keyLayout || "physical";
151
160
 
152
161
  const BBC = utils.BBC;
153
162
  const keyCodes = utils.keyCodes;
154
- let cpuMultiplier = 1;
163
+ const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
155
164
  let fastAsPossible = false;
156
165
  let fastTape = false;
157
166
  let noSeek;
@@ -217,27 +226,8 @@ const userPort = {
217
226
  },
218
227
  };
219
228
 
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
229
  // 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.
230
+ // Must be created before Config so the onClose callback and the initial checkbox state can reference it.
241
231
  const speechOutput = new SpeechOutput();
242
232
  speechOutput.enabled = !!parsedQuery.speechOutput;
243
233
 
@@ -253,23 +243,6 @@ const config = new Config(
253
243
  },
254
244
  function onClose(changed) {
255
245
  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
246
  if (changed.keyLayout) {
274
247
  window.localStorage.keyLayout = changed.keyLayout;
275
248
  emulationConfig.keyLayout = changed.keyLayout;
@@ -288,12 +261,22 @@ const config = new Config(
288
261
  if (changed.tubeCpuMultiplier !== undefined) {
289
262
  emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
290
263
  config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
291
- if (processor.tube && processor.tube.cpuMultiplier !== undefined) {
264
+ if (processor.hasTube) {
292
265
  processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
293
266
  }
294
267
  }
295
268
  updateUrl();
296
269
  },
270
+ function onRestartRequired() {
271
+ areYouSure(
272
+ "Your change is saved, but only takes effect when the emulator restarts. Restart now?",
273
+ "Restart now",
274
+ "Later",
275
+ function () {
276
+ window.location.reload();
277
+ },
278
+ );
279
+ },
297
280
  );
298
281
 
299
282
  // Perform mapping of legacy models to the new format
@@ -301,19 +284,45 @@ config.mapLegacyModels(parsedQuery);
301
284
 
302
285
  config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
303
286
  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);
287
+ config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || DefaultTubeCpuMultiplier);
309
288
  config.setMicrophoneChannel(parsedQuery.microphoneChannel);
310
- config.setMouseJoystickEnabled(parsedQuery.mouseJoystickEnabled);
311
- config.setSpeechOutput(speechOutput.enabled);
289
+ config.setCheckboxes({
290
+ coProcessor: !!parsedQuery.coProcessor,
291
+ hasEconet: !!parsedQuery.hasEconet,
292
+ hasMusic5000: !!parsedQuery.hasMusic5000,
293
+ hasTeletextAdaptor: !!parsedQuery.hasTeletextAdaptor,
294
+ mouseJoystickEnabled: !!parsedQuery.mouseJoystickEnabled,
295
+ speechOutput: speechOutput.enabled,
296
+ });
312
297
  let displayMode = parsedQuery.displayMode || "rgb";
313
298
  config.setDisplayMode(displayMode);
314
299
 
315
300
  model = config.model;
316
301
 
302
+ // Depends on the config.setX calls above having applied the URL parameters.
303
+ const emulationConfig = {
304
+ keyLayout,
305
+ cpuMultiplier,
306
+ tubeCpuMultiplier: config.tubeCpuMultiplier,
307
+ videoCyclesBatch: parsedQuery.videoCyclesBatch,
308
+ tube: config.coProcessor ? TubeModel : null,
309
+ hasMusic5000: config.hasMusic5000,
310
+ hasTeletextAdaptor: config.hasTeletextAdaptor,
311
+ // ROM order determines sideways bank allocation, and the fittings' ROMs claim banks
312
+ // before any the user asked for with ?rom=.
313
+ extraRoms: [...config.extraRoms, ...extraRoms],
314
+ userPort,
315
+ printerPort,
316
+ getGamepads: function () {
317
+ // Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
318
+ return navigator.getGamepads ? navigator.getGamepads() : [];
319
+ },
320
+ debugFlags: {
321
+ logFdcCommands: parsedQuery.logFdcCommands !== undefined,
322
+ logFdcStateChanges: parsedQuery.logFdcStateChanges !== undefined,
323
+ },
324
+ };
325
+
317
326
  function sbBind(div, url, onload) {
318
327
  const img = div.querySelector("img");
319
328
  img.style.display = "none";
@@ -335,10 +344,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
335
344
  div.style.bottom = -img.naturalHeight + "px";
336
345
  });
337
346
 
338
- if (parsedQuery.cpuMultiplier !== undefined) {
339
- cpuMultiplier = parsedQuery.cpuMultiplier;
340
- console.log("CPU multiplier set to " + cpuMultiplier);
341
- }
347
+ if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
342
348
  const cpuSpeed = model.isAtom ? 1 * 1000 * 1000 : 2 * 1000 * 1000;
343
349
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
344
350
  const MaxCyclesPerFrame = clocksPerSecond / 10;
@@ -536,10 +542,8 @@ const cubMonitor = document.getElementById("cub-monitor");
536
542
  function onCubMouseEvent(evt) {
537
543
  audioHandler.tryResume();
538
544
  if (document.activeElement !== document.body) document.activeElement.blur();
539
- const cubRect = cubMonitor.getBoundingClientRect();
540
545
  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;
546
+ const { x, y } = calculateMouseCoordinates(evt, screenRect);
543
547
 
544
548
  // Handle touchscreen
545
549
  if (processor.touchScreen) processor.touchScreen.onMouse(x, y, evt.buttons);
@@ -613,7 +617,7 @@ window.addEventListener("beforeunload", function (event) {
613
617
  }
614
618
  });
615
619
 
616
- if (model.hasEconet) {
620
+ if (config.hasEconet) {
617
621
  econet = new Econet(stationId);
618
622
  } else {
619
623
  document.getElementById("fsmenuitem").style.display = "none";
@@ -657,7 +661,7 @@ processor = new CpuClass(model, {
657
661
  soundChip: audioHandler.soundChip,
658
662
  ddNoise: audioHandler.ddNoise,
659
663
  relayNoise: audioHandler.relayNoise,
660
- music5000: model.hasMusic5000 ? audioHandler.music5000 : null,
664
+ music5000: config.hasMusic5000 ? audioHandler.music5000 : null,
661
665
  cmos,
662
666
  config: emulationConfig,
663
667
  econet,
@@ -1570,10 +1574,10 @@ async function loadStateFromFile(file, preReadBuffer) {
1570
1574
  }
1571
1575
  snapshot = snapshotFromJSON(text);
1572
1576
  }
1573
- if (!isSameModel(snapshot.model, model.name)) {
1574
- // Model mismatch: stash state and reload with correct model
1577
+ if (!isSameModel(snapshot.model, model.name) || hasCoProcessor(snapshot) !== processor.hasTube) {
1578
+ // Model or co-processor mismatch: stash state and reload with a matching machine
1575
1579
  sessionStorage.setItem("jsbeeb-pending-state", snapshotToJSON(snapshot));
1576
- const newQuery = { ...parsedQuery, model: snapshot.model };
1580
+ const newQuery = { ...parsedQuery, model: snapshot.model, coProcessor: hasCoProcessor(snapshot) };
1577
1581
  const baseUrl = window.location.origin + window.location.pathname;
1578
1582
  window.location.href = buildUrlFromParams(baseUrl, newQuery, paramTypes);
1579
1583
  return;
@@ -1691,7 +1695,7 @@ syncLights = function () {
1691
1695
  drive0.update(processor.fdc.motorOn[0]);
1692
1696
  drive1.update(processor.fdc.motorOn[1]);
1693
1697
  cassette.update(processor.acia.motorOn);
1694
- if (model.hasEconet) {
1698
+ if (processor.econet) {
1695
1699
  network.update(processor.econet.activityLight());
1696
1700
  }
1697
1701
  }
@@ -1849,14 +1853,22 @@ const aysEl = document.getElementById("are-you-sure");
1849
1853
  const aysModal = new bootstrap.Modal(aysEl);
1850
1854
 
1851
1855
  function areYouSure(message, yesText, noText, yesFunc) {
1856
+ const yesButton = aysEl.querySelector(".ays-yes");
1852
1857
  aysEl.querySelector(".context").textContent = message;
1853
- aysEl.querySelector(".ays-yes").textContent = yesText;
1854
1858
  aysEl.querySelector(".ays-no").textContent = noText;
1855
- aysEl.querySelector(".ays-yes").addEventListener(
1856
- "click",
1857
- function () {
1858
- aysModal.hide();
1859
- yesFunc();
1859
+ yesButton.textContent = yesText;
1860
+ let confirmed = false;
1861
+ const onYes = () => {
1862
+ confirmed = true;
1863
+ aysModal.hide();
1864
+ };
1865
+ yesButton.addEventListener("click", onYes, { once: true });
1866
+ // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
1867
+ aysEl.addEventListener(
1868
+ "hidden.bs.modal",
1869
+ () => {
1870
+ yesButton.removeEventListener("click", onYes);
1871
+ if (confirmed) yesFunc();
1860
1872
  },
1861
1873
  { once: true },
1862
1874
  );
package/src/models.js CHANGED
@@ -10,8 +10,16 @@ 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 } = {}) {
15
23
  this.name = name;
16
24
  this.synonyms = synonyms;
17
25
  this.os = os;
@@ -22,10 +30,7 @@ class Model {
22
30
  this.Fdc = fdc;
23
31
  this.swram = swram;
24
32
  this.isTest = false;
25
- this.tube = tube;
26
33
  this.cmosOverride = cmosOverride;
27
- this.hasEconet = false;
28
- this.hasMusic5000 = false;
29
34
  }
30
35
 
31
36
  get nmos() {
@@ -267,3 +272,18 @@ export const basicOnly = new Model({
267
272
  swram: masterSwram,
268
273
  fdc: NoiseAwareWdFdc,
269
274
  });
275
+
276
+ /**
277
+ * The only second processor jsbeeb emulates. Machine-building code passes this as the
278
+ * emulation config's `tube`, so that 6502.js needn't import this module and close an
279
+ * import cycle via the FDC modules.
280
+ */
281
+ export const TubeModel = findModel("Tube65C02");
282
+
283
+ // After the isTest assignments above, so those still apply.
284
+ for (const model of [...allModels, TEST_6502, TEST_65C02, TEST_65C12, basicOnly]) {
285
+ Object.freeze(model.os);
286
+ Object.freeze(model.synonyms);
287
+ Object.freeze(model);
288
+ }
289
+ 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
+ }