jsbeeb 1.14.0 → 1.16.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
@@ -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, DefaultTubeCpuMultiplier } from "./6502.js";
10
+ import { Cpu6502, AtomCpu6502 } 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,7 +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
+ import { tubeModelFor } from "./models.js";
23
23
  import { initialise as electron } from "./app/electron.js";
24
24
  import { AudioHandler } from "./web/audio-handler.js";
25
25
  import { Econet } from "./econet.js";
@@ -44,6 +44,8 @@ import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
44
44
  import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
45
45
  import { RewindBuffer } from "./rewind.js";
46
46
  import { RewindUI } from "./rewind-ui.js";
47
+ import { DiscVisualiser } from "./disc-visualiser.js";
48
+ import { downloadBlob } from "./dom-utils.js";
47
49
  import {
48
50
  buildUrlFromParams,
49
51
  guessModelFromHostname,
@@ -51,7 +53,7 @@ import {
51
53
  parseMediaParams,
52
54
  parseQueryString,
53
55
  processAutobootParams,
54
- processKeyboardParams,
56
+ processInputParams,
55
57
  } from "./url-params.js";
56
58
 
57
59
  let processor;
@@ -138,7 +140,7 @@ const paramTypes = {
138
140
  audiofilterfreq: ParamTypes.FLOAT,
139
141
  audiofilterq: ParamTypes.FLOAT,
140
142
  cpuMultiplier: ParamTypes.FLOAT,
141
- tubeCpuMultiplier: ParamTypes.INT,
143
+ tubeCpuMultiplier: ParamTypes.FLOAT,
142
144
  microphoneChannel: ParamTypes.INT,
143
145
 
144
146
  // String parameters (these are the default but listed for clarity)
@@ -176,9 +178,6 @@ const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage }
176
178
  if (queryDiscImage) discImage = queryDiscImage;
177
179
  if (querySecondDisc) secondDiscImage = querySecondDisc;
178
180
 
179
- // Process keyboard mappings
180
- parsedQuery = processKeyboardParams(parsedQuery, BBC, keyCodes, utils.userKeymap, gamepad);
181
-
182
181
  // Handle specific query parameters
183
182
  if (Array.isArray(parsedQuery.rom)) {
184
183
  parsedQuery.rom.forEach((romPath) => {
@@ -234,9 +233,10 @@ speechOutput.enabled = !!parsedQuery.speechOutput;
234
233
  const config = new Config(
235
234
  function onChange(changed) {
236
235
  if (changed.displayMode) {
237
- displayModeFilter = getFilterForMode(changed.displayMode);
236
+ // swapCanvas settles displayModeFilter on whatever was really
237
+ // built, so take the picture from that rather than the request.
238
+ swapCanvas(getFilterForMode(changed.displayMode));
238
239
  setCrtPic(displayModeFilter);
239
- swapCanvas(displayModeFilter);
240
240
  // Trigger window resize to recalculate layout with new dimensions
241
241
  window.dispatchEvent(new Event("resize"));
242
242
  }
@@ -284,7 +284,7 @@ config.mapLegacyModels(parsedQuery);
284
284
 
285
285
  config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
286
286
  config.setKeyLayout(keyLayout);
287
- config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || DefaultTubeCpuMultiplier);
287
+ config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
288
288
  config.setMicrophoneChannel(parsedQuery.microphoneChannel);
289
289
  config.setCheckboxes({
290
290
  coProcessor: !!parsedQuery.coProcessor,
@@ -299,13 +299,22 @@ config.setDisplayMode(displayMode);
299
299
 
300
300
  model = config.model;
301
301
 
302
+ // Must come after we know the model, to validate names against those of the hardware.
303
+ const keyMappingWarnings = processInputParams(
304
+ parsedQuery,
305
+ model.isAtom ? utils_atom.ATOM : BBC,
306
+ keyCodes,
307
+ utils.userKeymap,
308
+ gamepad,
309
+ );
310
+
302
311
  // Depends on the config.setX calls above having applied the URL parameters.
303
312
  const emulationConfig = {
304
313
  keyLayout,
305
314
  cpuMultiplier,
306
315
  tubeCpuMultiplier: config.tubeCpuMultiplier,
307
316
  videoCyclesBatch: parsedQuery.videoCyclesBatch,
308
- tube: config.coProcessor ? TubeModel : null,
317
+ tube: config.coProcessor ? tubeModelFor(config.model) : null,
309
318
  hasMusic5000: config.hasMusic5000,
310
319
  hasTeletextAdaptor: config.hasTeletextAdaptor,
311
320
  // ROM order determines sideways bank allocation, and the fittings' ROMs claim banks
@@ -345,7 +354,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
345
354
  });
346
355
 
347
356
  if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
348
- const cpuSpeed = model.isAtom ? 1 * 1000 * 1000 : 2 * 1000 * 1000;
357
+ const cpuSpeed = model.cyclesPerSecond;
349
358
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
350
359
  const MaxCyclesPerFrame = clocksPerSecond / 10;
351
360
 
@@ -374,12 +383,29 @@ function showError(context, error) {
374
383
  errorDialogModal.show();
375
384
  }
376
385
 
386
+ if (keyMappingWarnings.length) {
387
+ showError("applying the key mappings in the URL", keyMappingWarnings.join(" "));
388
+ }
389
+
377
390
  function createCanvasForFilter(filterClass) {
391
+ // Not `config`: that is the emulator's live configuration object, declared
392
+ // at module scope and used throughout this file.
393
+ const displayConfig = filterClass.getDisplayConfig();
394
+ // Each mode says how many pixels it wants to draw into. Set this before
395
+ // creating the context, which fixes its initial viewport.
396
+ screenCanvas.width = displayConfig.canvasWidth;
397
+ screenCanvas.height = displayConfig.canvasHeight;
398
+
378
399
  const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
379
400
 
380
- if (filterClass.requiresGl() && !newCanvas.isWebGl()) {
381
- const config = filterClass.getDisplayConfig();
382
- showError(`enabling ${config.name} mode`, `${config.name} requires WebGL. Using standard display instead.`);
401
+ // Test which filter was actually built, not merely whether we got WebGL: a
402
+ // filter can decline a context that works perfectly well for other modes,
403
+ // in which case bestCanvas quietly gives us an unfiltered GL canvas.
404
+ if (newCanvas.filterClass !== filterClass) {
405
+ showError(
406
+ `enabling ${displayConfig.name} mode`,
407
+ `${displayConfig.name} is not available on this device. Using standard display instead.`,
408
+ );
383
409
  }
384
410
 
385
411
  return newCanvas;
@@ -387,20 +413,33 @@ function createCanvasForFilter(filterClass) {
387
413
 
388
414
  let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
389
415
  function swapCanvas(newFilterClass) {
416
+ const oldCanvas = canvas;
390
417
  const newCanvas = createCanvasForFilter(newFilterClass);
418
+ // Carry the picture over; the buffers differ in height, so copy what fits.
419
+ newCanvas.fb32.set(oldCanvas.fb32.subarray(0, newCanvas.fb32.length));
420
+ // Only once the replacement exists, so a failure to build it leaves the
421
+ // display we already had. The two share a GL context but no GL objects.
422
+ oldCanvas.dispose();
391
423
  video.fb32 = newCanvas.fb32;
392
424
  video.paint_ext = function paint(minx, miny, maxx, maxy) {
393
425
  frames++;
394
426
  if (frames < frameSkip) return;
395
427
  frames = 0;
396
- newCanvas.paint(minx, miny, maxx, maxy, this.frameCount);
428
+ newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
397
429
  };
398
430
  canvas = newCanvas;
399
- displayModeFilter = newFilterClass;
431
+ // Follow the filter we ended up with, not the one we asked for: everything
432
+ // downstream — the monitor picture, the canvas geometry, how large a
433
+ // drawing buffer to ask for — comes from its display config.
434
+ displayModeFilter = newCanvas.filterClass;
435
+ // Nothing else will redraw: the mode is changed from a modal, which stops
436
+ // the emulator.
437
+ video.paint();
400
438
  window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
401
439
  }
402
440
 
403
441
  let canvas = createCanvasForFilter(displayModeFilter);
442
+ displayModeFilter = canvas.filterClass;
404
443
 
405
444
  video = new Video(
406
445
  model.isMaster,
@@ -409,7 +448,7 @@ video = new Video(
409
448
  frames++;
410
449
  if (frames < frameSkip) return;
411
450
  frames = 0;
412
- canvas.paint(minx, miny, maxx, maxy, this.frameCount);
451
+ canvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
413
452
  },
414
453
  { isAtom: model.isAtom },
415
454
  );
@@ -471,18 +510,8 @@ function replaceOrAddExtension(name, newExt) {
471
510
  * @param {string} extension - The file extension to use
472
511
  */
473
512
  function downloadDriveData(data, name, extension) {
474
- const a = document.createElement("a");
475
- document.body.appendChild(a);
476
- a.style = "display: none";
477
-
478
- const fileName = replaceOrAddExtension(name, extension);
479
513
  const blob = new Blob([data], { type: "application/octet-stream" });
480
- const url = window.URL.createObjectURL(blob);
481
-
482
- a.href = url;
483
- a.download = fileName;
484
- a.click();
485
- window.URL.revokeObjectURL(url);
514
+ downloadBlob(blob, replaceOrAddExtension(name, extension));
486
515
  }
487
516
 
488
517
  async function loadHTMLFile(file) {
@@ -532,7 +561,7 @@ pastetext.addEventListener("drop", async function (event) {
532
561
  await loadStateFromFile(file, arrayBuffer);
533
562
  } else if (file.name.toLowerCase().endsWith(".uef")) {
534
563
  // Regular UEF tape image (not a BeebEm save state)
535
- setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model.isAtom));
564
+ setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
536
565
  } else {
537
566
  await loadHTMLFile(file);
538
567
  }
@@ -618,7 +647,7 @@ window.addEventListener("beforeunload", function (event) {
618
647
  });
619
648
 
620
649
  if (config.hasEconet) {
621
- econet = new Econet(stationId);
650
+ econet = new Econet(stationId, model.cyclesPerSecond);
622
651
  } else {
623
652
  document.getElementById("fsmenuitem").style.display = "none";
624
653
  }
@@ -667,6 +696,8 @@ processor = new CpuClass(model, {
667
696
  econet,
668
697
  });
669
698
 
699
+ processor.teletextAdaptor?.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
700
+
670
701
  // Create input sources
671
702
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
672
703
  // Create MicrophoneInput but don't enable by default
@@ -681,18 +712,16 @@ const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
681
712
  /**
682
713
  * Attach an RS-423 composite handler to the ACIA that combines the touchscreen
683
714
  * (which sends position data to the BBC) with the speech output (which speaks
684
- * text the BBC sends out). Call this once after processor.initialise() and
685
- * again whenever speechOutput.enabled changes.
715
+ * text the BBC sends out).
686
716
  */
687
717
  function setupRs423Handler() {
688
- const touchScreen = processor.touchScreen;
689
718
  processor.acia.setRs423Handler({
690
719
  onTransmit(val) {
691
- touchScreen.onTransmit(val);
720
+ processor.touchScreen.onTransmit(val);
692
721
  speechOutput.onTransmit(val);
693
722
  },
694
723
  tryReceive(rts) {
695
- return touchScreen.tryReceive(rts);
724
+ return processor.touchScreen.tryReceive(rts);
696
725
  },
697
726
  });
698
727
  }
@@ -1206,17 +1235,16 @@ async function loadTapeImage(tapeImage) {
1206
1235
  const split = splitImage(tapeImage);
1207
1236
  tapeImage = split.image;
1208
1237
  const schema = split.schema;
1209
- const isAtom = model.isAtom;
1210
1238
 
1211
1239
  switch (schema) {
1212
1240
  case "|":
1213
1241
  case "sth":
1214
- return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), isAtom);
1242
+ return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), model);
1215
1243
 
1216
1244
  case "data": {
1217
1245
  const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
1218
1246
  const { name, data } = await utils.unzipDiscImage(arr);
1219
- return await loadTapeFromData(name, data, isAtom);
1247
+ return await loadTapeFromData(name, data, model);
1220
1248
  }
1221
1249
 
1222
1250
  case "http":
@@ -1231,7 +1259,7 @@ async function loadTapeImage(tapeImage) {
1231
1259
  tapeData = unzipped.data;
1232
1260
  tapeImage = unzipped.name;
1233
1261
  }
1234
- return await loadTapeFromData(tapeImage, tapeData, isAtom);
1262
+ return await loadTapeFromData(tapeImage, tapeData, model);
1235
1263
  }
1236
1264
 
1237
1265
  default: {
@@ -1243,7 +1271,7 @@ async function loadTapeImage(tapeImage) {
1243
1271
  tapeData = unzipped.data;
1244
1272
  tapeName = unzipped.name;
1245
1273
  }
1246
- return await loadTapeFromData(tapeName, tapeData, isAtom);
1274
+ return await loadTapeFromData(tapeName, tapeData, model);
1247
1275
  }
1248
1276
  }
1249
1277
  }
@@ -1276,7 +1304,7 @@ document.getElementById("tape_load").addEventListener("change", async function (
1276
1304
  tapeData = unzipped.data;
1277
1305
  tapeName = unzipped.name;
1278
1306
  }
1279
- setProcessorTape(await loadTapeFromData(tapeName, tapeData, model.isAtom));
1307
+ setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1280
1308
  delete parsedQuery.tape;
1281
1309
  updateUrl();
1282
1310
  bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
@@ -1445,7 +1473,12 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1445
1473
  let data;
1446
1474
  if (document.querySelector("#google-drive .create-from-existing").checked) {
1447
1475
  const discType = disc.guessDiscTypeFromName(name);
1448
- data = discType.saver(processor.fdc.drives[0].disc);
1476
+ try {
1477
+ data = discType.saver(processor.fdc.drives[0].disc);
1478
+ } catch (e) {
1479
+ loadingFinished(`Create failed: ${e.message}`);
1480
+ return;
1481
+ }
1449
1482
  name = replaceOrAddExtension(name, discType.extension);
1450
1483
  console.log(`Saving existing disc: ${name}`);
1451
1484
  } else {
@@ -1474,11 +1507,15 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1474
1507
 
1475
1508
  document.getElementById("download-drive-link").addEventListener("click", function () {
1476
1509
  const disc = processor.fdc.drives[0].disc;
1477
- const data = toSsdOrDsd(disc);
1478
- const name = disc.name;
1479
- const extension = disc.isDoubleSided ? ".dsd" : ".ssd";
1480
-
1481
- downloadDriveData(data, name, extension);
1510
+ const save = (options) =>
1511
+ downloadDriveData(toSsdOrDsd(disc, options), disc.name, disc.isDoubleSided ? ".dsd" : ".ssd");
1512
+ try {
1513
+ save();
1514
+ } catch (e) {
1515
+ areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
1516
+ save({ force: true }),
1517
+ );
1518
+ }
1482
1519
  });
1483
1520
 
1484
1521
  document.getElementById("download-drive-hfe-link").addEventListener("click", function () {
@@ -1539,13 +1576,8 @@ document.getElementById("save-state").addEventListener("click", async function (
1539
1576
  const snapshot = createSnapshot(processor, model, Object.keys(media).length > 0 ? media : undefined);
1540
1577
  const json = snapshotToJSON(snapshot);
1541
1578
  const blob = await compressBlob(new Blob([json]));
1542
- const url = URL.createObjectURL(blob);
1543
1579
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1544
- const a = document.createElement("a");
1545
- a.href = url;
1546
- a.download = `jsbeeb-${model.name}-${timestamp}.json.gz`;
1547
- a.click();
1548
- URL.revokeObjectURL(url);
1580
+ downloadBlob(blob, `jsbeeb-${model.name}-${timestamp}.json.gz`);
1549
1581
  } catch (e) {
1550
1582
  showError("saving state", e);
1551
1583
  }
@@ -1935,7 +1967,7 @@ function VirtualSpeedUpdater() {
1935
1967
  if (this.cycles) {
1936
1968
  const thisMHz = this.cycles / this.time / 1000;
1937
1969
  this.v.textContent = thisMHz.toFixed(1);
1938
- if (this.cycles >= 10 * 2 * 1000 * 1000) {
1970
+ if (this.cycles >= 10 * cpuSpeed) {
1939
1971
  this.cycles = this.time = 0;
1940
1972
  }
1941
1973
  this.header.style.color = this.speedy ? "red" : "white";
@@ -1963,6 +1995,9 @@ rewindUI = new RewindUI({
1963
1995
  });
1964
1996
  rewindUI.updateButtonState();
1965
1997
 
1998
+ if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
1999
+ else document.getElementById("disc-visualiser-open").classList.add("disabled");
2000
+
1966
2001
  function draw(now) {
1967
2002
  if (!running) {
1968
2003
  last = 0;
@@ -2078,6 +2113,9 @@ function stop(debug) {
2078
2113
  updateDebugButtons();
2079
2114
  }
2080
2115
 
2116
+ /** Steps the drawing buffer grows in, as a multiple of the base canvas size. */
2117
+ const CanvasScaleStep = 0.25;
2118
+
2081
2119
  (function () {
2082
2120
  const resizeCubMonitor = document.getElementById("cub-monitor");
2083
2121
  const resizeCubMonitorPic = document.getElementById("cub-monitor-pic");
@@ -2130,6 +2168,25 @@ function stop(debug) {
2130
2168
  resizeCubMonitor.style.width = width + "px";
2131
2169
  resizeCubMonitorPic.style.height = height + "px";
2132
2170
  resizeCubMonitorPic.style.width = width + "px";
2171
+ // A mode that reconstructs detail wants to draw at the size it will be
2172
+ // seen at, up to the limit it asks for. Drawing more than the display
2173
+ // can show costs fragments and buys nothing, and for an expensive
2174
+ // shader that is the difference between comfortable and not.
2175
+ if (displayConfig.maxCanvasScale) {
2176
+ const wanted = (finalCanvasWidth * (window.devicePixelRatio || 1)) / displayConfig.canvasWidth;
2177
+ // Quantised, because resize fires continuously while a window is
2178
+ // dragged and every distinct value reallocates the drawing buffer.
2179
+ const quantised = Math.round(wanted / CanvasScaleStep) * CanvasScaleStep;
2180
+ const scale = Math.min(displayConfig.maxCanvasScale, Math.max(1, quantised));
2181
+ const backingWidth = Math.round(displayConfig.canvasWidth * scale);
2182
+ if (screenCanvas.width !== backingWidth) {
2183
+ screenCanvas.width = backingWidth;
2184
+ screenCanvas.height = Math.round(displayConfig.canvasHeight * scale);
2185
+ // Resizing threw the drawing buffer away.
2186
+ video.paint();
2187
+ }
2188
+ }
2189
+
2133
2190
  screenCanvas.style.width = finalCanvasWidth + "px";
2134
2191
  screenCanvas.style.height = finalCanvasHeight + "px";
2135
2192
  screenCanvas.style.left = canvasOrigLeft * containerScale + "px";
package/src/models.js CHANGED
@@ -19,10 +19,12 @@ const CpuModel = Object.freeze({
19
19
  * to any number of machines without one session's settings leaking into the next.
20
20
  */
21
21
  class Model {
22
- constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, 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`);
23
24
  this.name = name;
24
25
  this.synonyms = synonyms;
25
26
  this.os = os;
27
+ this.clockMhz = clockMhz;
26
28
  this.banks = banks;
27
29
  this._cpuModel = cpuModel;
28
30
  this.isMaster = isMaster;
@@ -33,6 +35,15 @@ class Model {
33
35
  this.cmosOverride = cmosOverride;
34
36
  }
35
37
 
38
+ /**
39
+ * How many CPU cycles this machine runs in a second. Everything that
40
+ * converts between real time and emulated cycles should ask here rather
41
+ * than assuming a clock speed.
42
+ */
43
+ get cyclesPerSecond() {
44
+ return this.clockMhz * 1000 * 1000;
45
+ }
46
+
36
47
  get nmos() {
37
48
  return this._cpuModel === CpuModel.MOS6502;
38
49
  }
@@ -72,6 +83,7 @@ function atomModel({ name, synonyms, os, banks }) {
72
83
  os,
73
84
  cpuModel: CpuModel.MOS6502,
74
85
  isMaster: false,
86
+ clockMhz: 1,
75
87
  isAtom: true,
76
88
  swram: beebSwram,
77
89
  fdc: NoiseAwareIntelFdc,
@@ -124,6 +136,7 @@ export const allModels = [
124
136
  os: ["os.rom", "BASIC.ROM", "b/DFS-1.2.rom"],
125
137
  cpuModel: CpuModel.MOS6502,
126
138
  isMaster: false,
139
+ clockMhz: 2,
127
140
  swram: beebSwram,
128
141
  fdc: NoiseAwareIntelFdc,
129
142
  }),
@@ -133,6 +146,7 @@ export const allModels = [
133
146
  os: ["os.rom", "BASIC.ROM", "b/DFS-0.9.rom"],
134
147
  cpuModel: CpuModel.MOS6502,
135
148
  isMaster: false,
149
+ clockMhz: 2,
136
150
  swram: beebSwram,
137
151
  fdc: NoiseAwareIntelFdc,
138
152
  }),
@@ -142,6 +156,7 @@ export const allModels = [
142
156
  os: ["os.rom", "BASIC.ROM", "b1770/dfs1770.rom", "b1770/zADFS.ROM"],
143
157
  cpuModel: CpuModel.MOS6502,
144
158
  isMaster: false,
159
+ clockMhz: 2,
145
160
  swram: beebSwram,
146
161
  fdc: NoiseAwareWdFdc,
147
162
  }),
@@ -152,6 +167,7 @@ export const allModels = [
152
167
  os: ["os.rom", "BASIC.ROM", "b1770/zADFS.ROM", "b1770/dfs1770.rom"],
153
168
  cpuModel: CpuModel.MOS6502,
154
169
  isMaster: false,
170
+ clockMhz: 2,
155
171
  swram: beebSwram,
156
172
  fdc: NoiseAwareWdFdc,
157
173
  }),
@@ -161,6 +177,7 @@ export const allModels = [
161
177
  os: ["master/mos3.20"],
162
178
  cpuModel: CpuModel.CMOS65C12,
163
179
  isMaster: true,
180
+ clockMhz: 2,
164
181
  swram: masterSwram,
165
182
  fdc: NoiseAwareWdFdc,
166
183
  cmosOverride: pickDfs,
@@ -171,6 +188,7 @@ export const allModels = [
171
188
  os: ["master/mos3.20"],
172
189
  cpuModel: CpuModel.CMOS65C12,
173
190
  isMaster: true,
191
+ clockMhz: 2,
174
192
  swram: masterSwram,
175
193
  fdc: NoiseAwareWdFdc,
176
194
  cmosOverride: pickAdfs,
@@ -181,6 +199,7 @@ export const allModels = [
181
199
  os: ["master/mos3.20"],
182
200
  cpuModel: CpuModel.CMOS65C12,
183
201
  isMaster: true,
202
+ clockMhz: 2,
184
203
  swram: masterSwram,
185
204
  fdc: NoiseAwareWdFdc,
186
205
  cmosOverride: pickAnfs,
@@ -210,13 +229,25 @@ export const allModels = [
210
229
  synonyms: ["Atom-DOS"],
211
230
  os: ["atom/Atom_Kernel.rom", "atom/Atom_DOS.rom", "atom/Atom_FloatingPoint.rom", "atom/Atom_Basic.rom"],
212
231
  }),
213
- // Although this can not be explicitly selected as a model, it is required by the configuration builder later
232
+ // Neither can be selected as a model: they are fitted to one, by the configuration builder later.
214
233
  new Model({
215
234
  name: "Tube65C02",
216
235
  synonyms: [],
217
236
  os: ["tube/6502Tube.rom"],
237
+ // The production wedge's GTE 65SC02 has no Rockwell bit instructions.
238
+ cpuModel: CpuModel.CMOS65C12,
239
+ isMaster: false,
240
+ clockMhz: 3,
241
+ }),
242
+ new Model({
243
+ name: "Tube65C102",
244
+ synonyms: [],
245
+ os: ["tube/65C102Tube.rom"],
246
+ // Boards are reported with both Rockwell and GTE parts, so keep the superset of the two
247
+ // until #756 lets the fitted co-processor be chosen.
218
248
  cpuModel: CpuModel.CMOS65C02,
219
249
  isMaster: false,
250
+ clockMhz: 4,
220
251
  }),
221
252
  ];
222
253
 
@@ -236,6 +267,7 @@ export const TEST_6502 = new Model({
236
267
  name: "TEST",
237
268
  synonyms: ["TEST"],
238
269
  os: [],
270
+ clockMhz: 2,
239
271
  cpuModel: CpuModel.MOS6502,
240
272
  isMaster: false,
241
273
  swram: beebSwram,
@@ -246,6 +278,7 @@ export const TEST_65C02 = new Model({
246
278
  name: "TEST",
247
279
  synonyms: ["TEST"],
248
280
  os: [],
281
+ clockMhz: 2,
249
282
  cpuModel: CpuModel.CMOS65C02,
250
283
  isMaster: false,
251
284
  swram: masterSwram,
@@ -256,6 +289,7 @@ export const TEST_65C12 = new Model({
256
289
  name: "TEST",
257
290
  synonyms: ["TEST"],
258
291
  os: [],
292
+ clockMhz: 2,
259
293
  cpuModel: CpuModel.CMOS65C12,
260
294
  isMaster: false,
261
295
  swram: masterSwram,
@@ -265,6 +299,7 @@ TEST_65C12.isTest = true;
265
299
 
266
300
  export const basicOnly = new Model({
267
301
  name: "Basic only",
302
+ clockMhz: 2,
268
303
  synonyms: ["Basic only"],
269
304
  os: ["master/mos3.20"],
270
305
  cpuModel: CpuModel.CMOS65C12,
@@ -274,11 +309,18 @@ export const basicOnly = new Model({
274
309
  });
275
310
 
276
311
  /**
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.
312
+ * The second processors jsbeeb emulates: the external 3MHz box, and the Master Turbo's
313
+ * internal 4MHz board. Machine-building code passes one of these as the emulation config's
314
+ * `tube`, so that 6502.js needn't import this module and close an import cycle via the FDC
315
+ * modules.
280
316
  */
281
317
  export const TubeModel = findModel("Tube65C02");
318
+ export const TurboTubeModel = findModel("Tube65C102");
319
+
320
+ /** @returns {Model} the second processor sold for this machine: the Turbo board for a Master. */
321
+ export function tubeModelFor(model) {
322
+ return model.isMaster ? TurboTubeModel : TubeModel;
323
+ }
282
324
 
283
325
  // After the isTest assignments above, so those still apply.
284
326
  for (const model of [...allModels, TEST_6502, TEST_65C02, TEST_65C12, basicOnly]) {
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,
package/src/sth.js CHANGED
@@ -2,30 +2,33 @@
2
2
 
3
3
  import * as utils from "./utils.js";
4
4
 
5
- const catalogUrl = "reclist.php?sort=name&filter=.zip";
6
- const sthArchive = "www.stairwaytohell.com/bbc/archive";
5
+ // Always https, whatever the page was loaded over: the mirror redirects plain
6
+ // http, so following the page's protocol would cost a redirect on every request
7
+ // when developing over http, and Electron reports "file:" anyway.
8
+ const mirrorBase = "https://bbc.xania.org/archive/sth";
7
9
 
8
- async function _fetchAndParseCatalog(url) {
10
+ async function _fetchManifest(url) {
9
11
  const response = await fetch(url);
10
12
  if (!response.ok) {
11
- throw new Error("Network response was not ok");
13
+ throw new Error(`Network response was not ok (${response.status})`);
12
14
  }
13
- const parser = new DOMParser();
14
- const doc = parser.parseFromString(await response.text(), "text/html");
15
- const result = [];
16
- doc.querySelectorAll("tr td:nth-child(3) a").forEach((link) => {
17
- const href = link.getAttribute("href");
18
- if (href.indexOf(".zip") > 0) result.push(href);
19
- });
20
- result.sort();
21
- return result;
15
+ const data = await response.json();
16
+ if (!Array.isArray(data?.files)) {
17
+ throw new Error("Invalid manifest: missing files array");
18
+ }
19
+ return data.files.map((f) => f.path).sort();
20
+ }
21
+
22
+ // Each path component is encoded individually so slashes survive but special
23
+ // characters in filenames (e.g. brackets in "Daxis[droids]-demo.zip") don't
24
+ // produce a malformed URL.
25
+ function encodePath(path) {
26
+ return path.split("/").map(encodeURIComponent).join("/");
22
27
  }
23
28
 
24
29
  export class StairwayToHell {
25
30
  constructor(onStart, onCat, onError, tape) {
26
- // Use https explicitly - document.location.protocol is 'file:' in Electron
27
- const protocol = document.location.protocol === "file:" ? "https:" : document.location.protocol;
28
- this._baseUrl = `${protocol}//${sthArchive}/${tape ? "tape" : "disk"}images/`;
31
+ this._baseUrl = `${mirrorBase}/${tape ? "tape" : "disk"}images/`;
29
32
  this._catalog = [];
30
33
  this._onStart = onStart;
31
34
  this._onCat = onCat;
@@ -36,7 +39,7 @@ export class StairwayToHell {
36
39
  this._onStart();
37
40
  if (this._catalog.length === 0) {
38
41
  try {
39
- this._catalog = await _fetchAndParseCatalog(this._baseUrl + catalogUrl);
42
+ this._catalog = await _fetchManifest(this._baseUrl + "manifest.json");
40
43
  } catch (error) {
41
44
  console.error("Failed to fetch catalog:", error);
42
45
  if (this._onError) this._onError();
@@ -47,7 +50,7 @@ export class StairwayToHell {
47
50
  }
48
51
 
49
52
  async fetch(file) {
50
- const name = this._baseUrl + file;
53
+ const name = this._baseUrl + encodePath(file);
51
54
  console.log("Loading ZIP from " + name);
52
55
  const response = await fetch(name);
53
56
  if (!response.ok) throw new Error("Network response was not ok");