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.
@@ -255,7 +255,145 @@ function readString(data, pos) {
255
255
  return str;
256
256
  }
257
257
 
258
- function parseBemV3(buffer) {
258
+ // b-em writes the tube ULA as a raw struct dump. Version 2 is all single-byte fields, so it has
259
+ // no padding and can be read directly; version 1 used native ints and is not supported here.
260
+ const BemTubeUlaVersion = 2;
261
+ const BemTubeUlaOffsets = {
262
+ ph1: 0,
263
+ ph2: 24,
264
+ ph3: 25,
265
+ ph4: 27,
266
+ hp1: 29, // phl (byte 28) is a latch jsbeeb does not model
267
+ hp2: 30,
268
+ hp3: 31,
269
+ hp4: 33,
270
+ hstat: 35, // hpl (byte 34) likewise unmodelled
271
+ pstat: 39,
272
+ r1stat: 43,
273
+ ph1head: 45, // ph1tail (byte 44) matters only to b-em, which appends there
274
+ ph1count: 46,
275
+ ph3pos: 47,
276
+ hp3pos: 48,
277
+ };
278
+ const BemTubeUlaSize = 49;
279
+ const BemPh1Size = 24;
280
+ // 9 bytes of registers, then RAM, then ROM.
281
+ const BemTubeRegisterBytes = 9;
282
+ const BemTubeRamSize = 65536;
283
+ // b-em's other co-processors are different CPUs, and its "6502 Turbo" has 16MB of RAM.
284
+ const BemSupportedTubes = ["6502 Internal", "6502 External"];
285
+
286
+ /**
287
+ * Convert b-em's tube sections into jsbeeb tube state.
288
+ * @param {object} sections - parsed v3 sections
289
+ * @param {string|null} tubeName - co-processor name from the model section
290
+ * @returns {Promise<object|null>} jsbeeb tube state, or null if no usable co-processor
291
+ */
292
+ async function parseBemTube(sections, tubeName) {
293
+ // b-em names the model's co-processor even when it failed to start one, so naming a tube but
294
+ // carrying no state for it is valid. Carrying only one of the two sections is not.
295
+ if (!sections["T"] && !sections["P"]) return null;
296
+ if (!sections["T"] || !sections["P"]) {
297
+ throw new Error("Truncated b-em snapshot: it holds only one of the two co-processor sections");
298
+ }
299
+ // b-em only writes these sections for a co-processor it started, which it always names.
300
+ if (!tubeName) {
301
+ throw new Error("Corrupt b-em snapshot: it holds co-processor state but names no co-processor");
302
+ }
303
+ if (!BemSupportedTubes.includes(tubeName)) {
304
+ throw new Error(
305
+ `Unsupported b-em co-processor "${tubeName}": jsbeeb only emulates the 64K 65C02 second processor`,
306
+ );
307
+ }
308
+
309
+ const ulaSection = sections["T"].data;
310
+ if (ulaSection[0] !== BemTubeUlaVersion) {
311
+ throw new Error(`Unsupported b-em tube ULA state version ${ulaSection[0]}`);
312
+ }
313
+ if (ulaSection.length < 2 + BemTubeUlaSize) {
314
+ throw new Error(
315
+ `Truncated b-em tube ULA state: expected ${2 + BemTubeUlaSize} bytes, got ${ulaSection.length}`,
316
+ );
317
+ }
318
+ const romPaged = !!ulaSection[1];
319
+ const ula = ulaSection.slice(2, 2 + BemTubeUlaSize);
320
+ const at = (field) => ula[BemTubeUlaOffsets[field]];
321
+
322
+ // b-em keeps the R1 FIFO as a circular buffer; jsbeeb shifts its contents down on read, so
323
+ // the bytes have to be linearised into the order jsbeeb expects to read them.
324
+ const ph1Count = at("ph1count");
325
+ const ph1Head = at("ph1head");
326
+ // Out-of-range counts would restore a FIFO jsbeeb's own reads could never have produced.
327
+ if (ph1Count > BemPh1Size || ph1Head >= BemPh1Size || at("ph3pos") > 2 || at("hp3pos") > 2) {
328
+ throw new Error("Corrupt b-em tube ULA state: FIFO counts out of range");
329
+ }
330
+ const ph1 = new Uint8Array(BemPh1Size);
331
+ for (let i = 0; i < ph1Count; i++) {
332
+ ph1[i] = ula[BemTubeUlaOffsets.ph1 + ((ph1Head + i) % BemPh1Size)];
333
+ }
334
+
335
+ // Those names come from a config file, so size is the real check. It cannot be exact: the
336
+ // trailing ROM's size comes from that same config, and b-em records it as zero if unset.
337
+ const parasite = await decompress(sections["P"].data, "deflate");
338
+ const romBytes = parasite.length - BemTubeRegisterBytes - BemTubeRamSize;
339
+ if (romBytes < 0 || romBytes > BemTubeRamSize) {
340
+ throw new Error(
341
+ `Unsupported b-em co-processor: expected ${BemTubeRamSize} bytes of parasite RAM, ` +
342
+ `but the state section holds ${parasite.length - BemTubeRegisterBytes}`,
343
+ );
344
+ }
345
+
346
+ return {
347
+ a: parasite[2],
348
+ x: parasite[3],
349
+ y: parasite[4],
350
+ p: parasite[5] | 0x30,
351
+ s: parasite[6],
352
+ pc: parasite[7] | (parasite[8] << 8),
353
+ // b-em's oldnmi is the NMI level as of the parasite's last instruction, so it may lag the
354
+ // line its ULA state implies. b-em's skipint has no jsbeeb equivalent and is dropped.
355
+ nmiLevel: !!parasite[1],
356
+ nmiEdge: false,
357
+ takeInt: false,
358
+ cycles: 0,
359
+ romPaged,
360
+ memory: parasite.slice(BemTubeRegisterBytes, BemTubeRegisterBytes + BemTubeRamSize),
361
+ // The parasite ROM is loaded from file at boot, so b-em's copy is not needed.
362
+ ula: {
363
+ internalStatusRegister: at("r1stat"),
364
+ hostStatus: ula.slice(BemTubeUlaOffsets.hstat, BemTubeUlaOffsets.hstat + 4),
365
+ parasiteStatus: ula.slice(BemTubeUlaOffsets.pstat, BemTubeUlaOffsets.pstat + 4),
366
+ parasiteToHostData: [
367
+ ph1,
368
+ Uint8Array.of(at("ph2")),
369
+ ula.slice(BemTubeUlaOffsets.ph3, BemTubeUlaOffsets.ph3 + 2),
370
+ Uint8Array.of(at("ph4")),
371
+ ],
372
+ hostToParasiteData: [
373
+ Uint8Array.of(at("hp1")),
374
+ Uint8Array.of(at("hp2")),
375
+ ula.slice(BemTubeUlaOffsets.hp3, BemTubeUlaOffsets.hp3 + 2),
376
+ Uint8Array.of(at("hp4")),
377
+ ],
378
+ parasiteToHostFifoByteCount1: ph1Count,
379
+ parasiteToHostFifoByteCount3: at("ph3pos"),
380
+ hostToParasiteFifoByteCount3: at("hp3pos"),
381
+ },
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Read the co-processor name from b-em's model section, if one was fitted.
387
+ * @returns {string|null}
388
+ */
389
+ function readBemTubeName(data, pos) {
390
+ for (let i = 0; i < 4; i++) readString(data, pos); // os, cmos, rom setup, fdc names
391
+ pos.offset += 6; // model flag bytes
392
+ const fitted = data[pos.offset++];
393
+ return fitted ? readString(data, pos) : null;
394
+ }
395
+
396
+ async function parseBemV3(buffer) {
259
397
  const bytes = new Uint8Array(buffer);
260
398
  let offset = 8; // Skip "BEMSNAP3" signature
261
399
 
@@ -281,11 +419,13 @@ function parseBemV3(buffer) {
281
419
  // Parse model section to determine jsbeeb model name.
282
420
  // Use jsbeeb synonyms (from models.js) so findModel() resolves them.
283
421
  let modelName = "B";
422
+ let tubeName = null;
284
423
  if (sections["m"]) {
285
424
  const pos = { offset: 0 };
286
425
  const data = sections["m"].data;
287
426
  readVar(data, pos); // curmodel index (skip)
288
427
  const name = readString(data, pos);
428
+ tubeName = readBemTubeName(data, pos);
289
429
  if (name.includes("Master")) {
290
430
  if (name.includes("ADFS")) modelName = "MasterADFS";
291
431
  else if (name.includes("ANFS")) modelName = "MasterANFS";
@@ -311,23 +451,21 @@ function parseBemV3(buffer) {
311
451
  if (memSection) {
312
452
  // Memory is zlib-compressed; decompression is async.
313
453
  // Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
314
- return decompress(memSection.data, "deflate").then((memData) => {
315
- cpuState.fe30 = memData[0];
316
- cpuState.fe34 = memData[1];
317
- const ramStart = 2;
318
- const ramSize = 64 * 1024;
319
- ram.set(memData.slice(ramStart, ramStart + ramSize));
320
- const romStart = ramStart + ramSize;
321
- if (memData.length > romStart) {
322
- roms = memData.slice(romStart, romStart + 262144);
323
- }
324
- return finishV3Parse(modelName, cpuState, ram, roms, sections);
325
- });
454
+ const memData = await decompress(memSection.data, "deflate");
455
+ cpuState.fe30 = memData[0];
456
+ cpuState.fe34 = memData[1];
457
+ const ramStart = 2;
458
+ const ramSize = 64 * 1024;
459
+ ram.set(memData.slice(ramStart, ramStart + ramSize));
460
+ const romStart = ramStart + ramSize;
461
+ if (memData.length > romStart) {
462
+ roms = memData.slice(romStart, romStart + 262144);
463
+ }
326
464
  }
327
- return finishV3Parse(modelName, cpuState, ram, roms, sections);
465
+ return finishV3Parse(modelName, cpuState, ram, roms, sections, await parseBemTube(sections, tubeName));
328
466
  }
329
467
 
330
- function finishV3Parse(modelName, cpuState, ram, roms, sections) {
468
+ function finishV3Parse(modelName, cpuState, ram, roms, sections, tube) {
331
469
  // Parse system VIA
332
470
  let sysvia = convertViaState(
333
471
  {
@@ -466,5 +604,6 @@ function finishV3Parse(modelName, cpuState, ram, roms, sections) {
466
604
  uservia,
467
605
  buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, crtcCounters),
468
606
  soundChip,
607
+ tube,
469
608
  );
470
609
  }
package/src/config.js CHANGED
@@ -1,31 +1,93 @@
1
1
  "use strict";
2
- import { allModels, findModel } from "./models.js";
2
+ import { allModels, findModel, tubeModelFor } from "./models.js";
3
3
  import { getFilterForMode } from "./canvas.js";
4
4
 
5
+ const round = (value) => Number(value.toFixed(2));
6
+
7
+ /** @returns {string} the speed a multiplier gives this machine's co-processor, e.g. "1.6x (4.8MHz)". */
8
+ export function tubeCpuSpeedLabel(multiplier, model) {
9
+ return `${round(multiplier)}x (${round(multiplier * tubeModelFor(model).clockMhz)}MHz)`;
10
+ }
11
+
12
+ /**
13
+ * The sideways ROMs the optional fittings need, in the order they claim banks.
14
+ *
15
+ * @param {{model: object, hasEconet: boolean, hasMusic5000: boolean, hasTeletextAdaptor: boolean}} settings
16
+ * @returns {string[]}
17
+ */
18
+ export function fittedRoms({ model, hasEconet, hasMusic5000, hasTeletextAdaptor }) {
19
+ return [
20
+ ...(hasEconet && model.isMaster ? ["master/anfs-4.25.rom"] : []),
21
+ ...(hasMusic5000 ? ["ample.rom"] : []),
22
+ ...(hasTeletextAdaptor ? ["ats-3.0.rom"] : []),
23
+ ];
24
+ }
25
+
26
+ /** The settings the dialog presents as checkboxes. `enables` names a control only usable while ticked. */
27
+ export const CheckboxSettings = [
28
+ { id: "65c02", field: "coProcessor", restartRequired: true, enables: "tubeCpuMultiplier" },
29
+ { id: "hasTeletextAdaptor", field: "hasTeletextAdaptor", restartRequired: true },
30
+ { id: "hasEconet", field: "hasEconet", restartRequired: true },
31
+ { id: "hasMusic5000", field: "hasMusic5000", restartRequired: true },
32
+ { id: "mouseJoystickEnabled", field: "mouseJoystickEnabled" },
33
+ { id: "speechOutput", field: "speechOutput" },
34
+ ];
35
+
36
+ /** The model is not a checkbox, but changing it needs a restart just the same. */
37
+ const RestartRequiredFields = [
38
+ "model",
39
+ ...CheckboxSettings.filter((setting) => setting.restartRequired).map((setting) => setting.field),
40
+ ];
41
+
42
+ /** @returns {boolean} whether any of the changed settings only take effect on a freshly built machine. */
43
+ export function needsRestart(changed) {
44
+ return RestartRequiredFields.some((field) => field in changed);
45
+ }
46
+
47
+ /** @returns {boolean} whether the saved settings differ from those the running machine was built with. */
48
+ export function restartPending(settings, running) {
49
+ return RestartRequiredFields.some((field) => settings[field] !== running[field]);
50
+ }
51
+
5
52
  export class Config extends EventTarget {
6
- constructor(onChange, onClose) {
53
+ /**
54
+ * @param {function(object)} onChange called as soon as a setting the emulator can follow live changes
55
+ * @param {function(object)} onClose called with the settings to apply and persist
56
+ * @param {function()} onRestartRequired called after the settings have been saved, when some of them
57
+ * will only take effect once the machine is rebuilt
58
+ */
59
+ constructor(onChange, onClose, onRestartRequired) {
7
60
  super();
8
61
  this.onChange = onChange;
9
62
  this.onClose = onClose;
63
+ this.onRestartRequired = onRestartRequired;
10
64
  this.changed = {};
11
65
  this.model = null;
12
- this.coProcessor = null;
66
+ for (const { field } of CheckboxSettings) this[field] = false;
67
+ this.runningSettings = null;
13
68
  const configuration = document.getElementById("configuration");
14
69
  configuration.addEventListener("show.bs.modal", () => {
15
70
  this.changed = {};
71
+ // The startup settings are pushed in after construction, so what the running machine was
72
+ // built with is only knowable from the first time the dialog is opened.
73
+ if (!this.runningSettings) this.runningSettings = this.proposedSettings();
16
74
  this.setDropdownText(this.model.name);
17
- this.set65c02(this.model.tube);
18
75
  this.setTubeCpuMultiplier(this.tubeCpuMultiplier);
19
- this.setTeletext(this.model.hasTeletextAdaptor);
20
- this.setMusic5000(this.model.hasMusic5000);
21
- this.setEconet(this.model.hasEconet);
76
+ this.setCheckboxes(this);
77
+ this.showRestartPending();
22
78
  });
23
79
 
24
80
  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
- }
81
+ const changed = this.changed;
82
+ // Not setModel: that also renames the machine in the title bar, which has not changed yet.
83
+ if (changed.model !== undefined) this.model = findModel(changed.model);
84
+ this.setCheckboxes(changed);
85
+ this.onClose(changed);
86
+ if (Object.keys(changed).length === 0) return;
87
+ this.dispatchEvent(new CustomEvent("settings-changed", { detail: changed }));
88
+ // changed records which controls were touched, so a value in it can be what is already running.
89
+ if (needsRestart(changed) && restartPending(this.proposedSettings(), this.runningSettings))
90
+ this.onRestartRequired();
29
91
  });
30
92
 
31
93
  const modelMenu = document.querySelector(".model-menu");
@@ -45,31 +107,25 @@ export class Config extends EventTarget {
45
107
  if (!link) return;
46
108
  this.changed.model = link.dataset.target;
47
109
  this.setDropdownText(link.textContent);
110
+ this.showTubeCpuMultiplier(this.tubeCpuMultiplier, findModel(link.dataset.target));
111
+ this.showRestartPending();
48
112
  });
49
113
 
50
- document.getElementById("65c02").addEventListener("click", () => {
51
- this.changed.coProcessor = document.getElementById("65c02").checked;
52
- document.getElementById("tubeCpuMultiplier").disabled = !document.getElementById("65c02").checked;
53
- });
114
+ for (const { id, field, enables } of CheckboxSettings) {
115
+ document.getElementById(id).addEventListener("click", () => {
116
+ const checked = document.getElementById(id).checked;
117
+ this.changed[field] = checked;
118
+ if (enables) document.getElementById(enables).disabled = !checked;
119
+ this.showRestartPending();
120
+ });
121
+ }
54
122
 
55
123
  document.getElementById("tubeCpuMultiplier").addEventListener("input", () => {
56
- const val = parseInt(document.getElementById("tubeCpuMultiplier").value, 10);
57
- document.getElementById("tubeCpuMultiplierValue").textContent = val;
124
+ const val = parseFloat(document.getElementById("tubeCpuMultiplier").value);
125
+ this.showTubeCpuMultiplier(val);
58
126
  this.changed.tubeCpuMultiplier = val;
59
127
  });
60
128
 
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
129
  for (const link of document.querySelectorAll(".keyboard-menu a")) {
74
130
  link.addEventListener("click", (e) => {
75
131
  const keyLayout = e.target.dataset.target;
@@ -87,14 +143,6 @@ export class Config extends EventTarget {
87
143
  });
88
144
  }
89
145
 
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
146
  for (const option of document.querySelectorAll(".display-mode-option")) {
99
147
  option.addEventListener("click", (e) => {
100
148
  const mode = e.target.dataset.mode;
@@ -105,17 +153,34 @@ export class Config extends EventTarget {
105
153
  }
106
154
  }
107
155
 
108
- setMicrophoneChannel(channel) {
109
- const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
110
- for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
156
+ /**
157
+ * The restart-required settings as they would be saved if the dialog were closed now, in the form the
158
+ * menu and the URL use: the model by synonym rather than resolved, the fittings as booleans.
159
+ */
160
+ proposedSettings() {
161
+ const saved = (field) => (field === "model" ? this.model.synonyms[0] : this[field]);
162
+ return Object.fromEntries(RestartRequiredFields.map((field) => [field, this.changed[field] ?? saved(field)]));
111
163
  }
112
164
 
113
- setMouseJoystickEnabled(enabled) {
114
- document.getElementById("mouseJoystickEnabled").checked = !!enabled;
165
+ showRestartPending() {
166
+ const pending = restartPending(this.proposedSettings(), this.runningSettings);
167
+ document.getElementById("restart-pending").classList.toggle("d-none", !pending);
115
168
  }
116
169
 
117
- setSpeechOutput(enabled) {
118
- document.getElementById("speechOutput").checked = !!enabled;
170
+ /** Ticks the boxes named in `values` and adopts them, leaving any the object does not mention alone. */
171
+ setCheckboxes(values) {
172
+ for (const { id, field, enables } of CheckboxSettings) {
173
+ if (values[field] === undefined) continue;
174
+ const checked = !!values[field];
175
+ document.getElementById(id).checked = checked;
176
+ this[field] = checked;
177
+ if (enables) document.getElementById(enables).disabled = !checked;
178
+ }
179
+ }
180
+
181
+ setMicrophoneChannel(channel) {
182
+ const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
183
+ for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
119
184
  }
120
185
 
121
186
  setDisplayMode(mode) {
@@ -133,41 +198,14 @@ export class Config extends EventTarget {
133
198
  for (const el of document.querySelectorAll(".keyboard-layout")) el.textContent = text;
134
199
  }
135
200
 
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
201
  setTubeCpuMultiplier(value) {
144
202
  this.tubeCpuMultiplier = value;
145
203
  document.getElementById("tubeCpuMultiplier").value = value;
146
- document.getElementById("tubeCpuMultiplierValue").textContent = value;
147
- }
148
-
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
- }
204
+ this.showTubeCpuMultiplier(value);
157
205
  }
158
206
 
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);
207
+ showTubeCpuMultiplier(value, model = this.model) {
208
+ document.getElementById("tubeCpuMultiplierValue").textContent = tubeCpuSpeedLabel(value, model);
171
209
  }
172
210
 
173
211
  setDropdownText(modelName) {
@@ -175,15 +213,8 @@ export class Config extends EventTarget {
175
213
  if (el) el.textContent = modelName;
176
214
  }
177
215
 
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
- }
216
+ get extraRoms() {
217
+ return fittedRoms(this);
187
218
  }
188
219
 
189
220
  mapLegacyModels(parsedQuery) {
package/src/disc.js CHANGED
@@ -672,11 +672,56 @@ export function loadAdf(disc, data, isDsd) {
672
672
  return disc;
673
673
  }
674
674
 
675
+ /** Why a sector will not fit in an SSD or DSD image, or null if it will. */
676
+ function sectorShortfall(sector, trackNum) {
677
+ if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
678
+ // A header whose data mark never arrives leaves the sector with nothing to write.
679
+ if (!sector.sectorData) return "with no data";
680
+ if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
681
+ return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
682
+ if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
683
+ if (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
684
+ return null;
685
+ }
686
+
687
+ /**
688
+ * SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
689
+ * have held is lost. Copy protection usually shows up as one of these.
690
+ *
691
+ * @returns {string[]} what `disc` holds that an SSD or DSD cannot, worst first
692
+ * @param {Disc} disc
693
+ */
694
+ export function ssdOrDsdShortfalls(disc) {
695
+ const counts = new Map();
696
+ for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
697
+ for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
698
+ for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
699
+ const shortfall = sectorShortfall(sector, trackNum);
700
+ if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
701
+ }
702
+ }
703
+ }
704
+ return [...counts]
705
+ .sort(([, a], [, b]) => b - a)
706
+ .map(([shortfall, count]) => `${count} sector${count === 1 ? "" : "s"} ${shortfall}`);
707
+ }
708
+
675
709
  /**
676
710
  * @returns {Uint8Array}
677
711
  * @param {Disc} disc
712
+ * @param {object} [options]
713
+ * @param {boolean} [options.force] save what fits instead of refusing a disc that will not fit
714
+ * @throws if the disc holds anything an SSD or DSD cannot, and `force` is not set
678
715
  */
679
- export function toSsdOrDsd(disc) {
716
+ export function toSsdOrDsd(disc, { force = false } = {}) {
717
+ if (!force) {
718
+ const shortfalls = ssdOrDsdShortfalls(disc);
719
+ if (shortfalls.length)
720
+ throw new Error(
721
+ `This disc cannot be saved as SSD or DSD: it has ${shortfalls.join(", ")}. ` +
722
+ `Save it as HFE to keep everything.`,
723
+ );
724
+ }
680
725
  const numSides = disc.isDoubleSided ? 2 : 1;
681
726
  const result = new Uint8Array(
682
727
  numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
@@ -686,11 +731,8 @@ export function toSsdOrDsd(disc) {
686
731
  for (let side = 0; side < numSides; ++side) {
687
732
  const trackObj = disc.getTrack(side === 1, trackNum);
688
733
  for (const sector of trackObj.findSectors()) {
734
+ if (sectorShortfall(sector, trackNum)) continue;
689
735
  const sectorOffset = offset + sector.sectorNumber * SsdFormat.sectorSize;
690
- if (sector.hasDataCrcError || sector.hasHeaderCrcError) {
691
- console.log(`Skipping sector ${sector.description} with bad CRC`);
692
- continue;
693
- }
694
736
  for (let x = 0; x < SsdFormat.sectorSize; ++x) result[sectorOffset + x] = sector.sectorData[x];
695
737
  }
696
738
  offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
package/src/dom-utils.js CHANGED
@@ -30,3 +30,19 @@ export function fadeOut(el, duration = 400) {
30
30
  if (el.style.opacity === "0") el.style.display = "none";
31
31
  }, duration);
32
32
  }
33
+
34
+ // Safari fetches the blob a task or more after the click, so the URL must outlive it.
35
+ // 40s matches FileSaver.js.
36
+ const BlobUrlLifetimeMs = 40000;
37
+
38
+ /** Save a blob to the user's downloads under the given file name. */
39
+ export function downloadBlob(blob, fileName) {
40
+ const url = URL.createObjectURL(blob);
41
+ const a = document.createElement("a");
42
+ a.href = url;
43
+ a.download = fileName;
44
+ document.body.appendChild(a);
45
+ a.click();
46
+ a.remove();
47
+ setTimeout(() => URL.revokeObjectURL(url), BlobUrlLifetimeMs);
48
+ }
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, tubeModelFor } 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 ? tubeModelFor(model) : null,
34
+ tubeCpuMultiplier: opts.tubeCpuMultiplier,
35
+ cpuMultiplier: opts.cpuMultiplier,
36
+ hasTeletextAdaptor: opts.hasTeletextAdaptor,
37
+ },
33
38
  });
34
39
  }
35
40
 
package/src/gamepads.js CHANGED
@@ -39,16 +39,21 @@ export class GamePad {
39
39
  this.gamepadAxisMapping[3][-1] = BBC.COLON_STAR; // up
40
40
  this.gamepadAxisMapping[3][1] = BBC.SLASH; // down
41
41
  */
42
+ /**
43
+ * Maps a gamepad button or stick direction to a BBC key.
44
+ * @param {string} gamepadKey - the gamepad control, eg `FIRE2`
45
+ * @param {string} bbcKey - the BBC key to press, eg `RETURN`
46
+ * @returns {?string} a description of the problem, or null if the mapping was applied
47
+ */
42
48
  remap(gamepadKey, bbcKey) {
43
49
  // convert "1" into "K1"
44
- if ("0123456789".indexOf(bbcKey) > 0) {
50
+ if (bbcKey.length === 1 && bbcKey >= "0" && bbcKey <= "9") {
45
51
  bbcKey = "K" + bbcKey;
46
52
  }
47
53
 
48
54
  const mappedBbcKey = BBC[bbcKey];
49
55
  if (!mappedBbcKey) {
50
- console.log("unknown BBC key: " + bbcKey);
51
- return;
56
+ return `unknown BBC key "${bbcKey}".`;
52
57
  }
53
58
 
54
59
  switch (gamepadKey) {
@@ -152,8 +157,10 @@ export class GamePad {
152
157
  this.gamepadMapping[6] = mappedBbcKey;
153
158
  break;
154
159
  default:
155
- console.log("unknown gamepad key: " + gamepadKey);
160
+ return `unknown gamepad control "${gamepadKey}".`;
156
161
  }
162
+
163
+ return null;
157
164
  }
158
165
 
159
166
  update(sysvia) {
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 = [];