jsbeeb 1.15.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/disc.js CHANGED
@@ -257,10 +257,12 @@ class Sector {
257
257
  * @param {Track} track
258
258
  * @param {boolean} isMfm
259
259
  * @param {Number} idPosBitOffset
260
+ * @param {function(string): void} [warn] where to report anomalies; the console by default
260
261
  */
261
- constructor(track, isMfm, idPosBitOffset) {
262
+ constructor(track, isMfm, idPosBitOffset, warn = console.log) {
262
263
  this.track = track;
263
264
  this.isMfm = isMfm;
265
+ this._warn = warn;
264
266
  this.idPosBitOffset = idPosBitOffset;
265
267
  this.dataPosBitOffset = null;
266
268
  this.isDeleted = false;
@@ -271,7 +273,7 @@ class Sector {
271
273
  const idReader = this._readerAt(this.idPosBitOffset);
272
274
  const { data: headerData, iffyPulses } = idReader.read(6);
273
275
  if (iffyPulses) {
274
- console.log(`Iffy pulse in sector header ${this.description}`);
276
+ this._warn(`Iffy pulse in sector header ${this.description}`);
275
277
  }
276
278
  this.header = headerData;
277
279
  let crc = idReader.initialCrc;
@@ -304,7 +306,7 @@ class Sector {
304
306
  read(nextSector) {
305
307
  const pulsesPerByte = this.isMfm ? 16 : 32; // todo put in reader
306
308
  if (this.dataPosBitOffset === null) {
307
- console.log(`"Sector header without data ${this.description}"`);
309
+ this._warn(`Sector header without data ${this.description}`);
308
310
  return;
309
311
  }
310
312
 
@@ -331,7 +333,7 @@ class Sector {
331
333
  sectorSize = sectorSize >>> 1;
332
334
  } while (sectorSize >= 128);
333
335
  if (seenIffyData) {
334
- console.log(`"Iffy pulse in sector data ${this.description}"`);
336
+ this._warn(`Iffy pulse in sector data ${this.description}`);
335
337
  }
336
338
  }
337
339
 
@@ -355,6 +357,61 @@ class Sector {
355
357
  }
356
358
  }
357
359
 
360
+ /**
361
+ * A 64 bit shift register, held as a pair of unsigned 32 bit halves. BigInt would say this more
362
+ * directly but costs an order of magnitude more per bit shifted.
363
+ */
364
+ export class BitWindow64 {
365
+ constructor() {
366
+ this.hi = 0;
367
+ this.lo = 0;
368
+ }
369
+
370
+ /**
371
+ * @param {Number} bit 0 or 1, shifted into bit 0
372
+ * @returns {Number} the bit shifted out of bit 63
373
+ */
374
+ shiftIn(bit) {
375
+ const shiftedOut = this.hi >>> 31;
376
+ this.hi = ((this.hi << 1) | (this.lo >>> 31)) >>> 0;
377
+ this.lo = ((this.lo << 1) | bit) >>> 0;
378
+ return shiftedOut;
379
+ }
380
+
381
+ /**
382
+ * @returns {boolean} whether all 64 bits match the halves given
383
+ */
384
+ equals(hi, lo) {
385
+ return this.hi === hi && this.lo === lo;
386
+ }
387
+
388
+ /**
389
+ * @param {Number} nibble
390
+ * @returns {Number} the length of the run of `nibble` ending at bit 0
391
+ */
392
+ countTrailingNibbles(nibble) {
393
+ const nibblesPerHalf = 8;
394
+ let count = 0;
395
+ for (let bits = this.lo; (bits & 0xf) === nibble; bits >>>= 4) count++;
396
+ // Only a low half that matched all the way up can have a run continuing into the high half.
397
+ if (count === nibblesPerHalf) for (let bits = this.hi; (bits & 0xf) === nibble; bits >>>= 4) count++;
398
+ return count;
399
+ }
400
+ }
401
+
402
+ // What the mark detector holds at an address mark. The FM case only pins down the high half,
403
+ // the tail of the zero sync run; the low half is the marker byte itself, decoded separately.
404
+ const FmSyncHi = 0x88888888;
405
+ const MfmMarkerHi = 0xaaaa4489;
406
+ const MfmMarkerLo = 0x44894489;
407
+ // One data bit of zero, FM encoded with its clock, is the pulse nibble 0x8.
408
+ const FmZeroBitPulses = 0x8;
409
+ // FmSyncHi is eight of those, so a match has already seen this much of the sync run.
410
+ const FmSyncHiZeroBits = 8;
411
+ // Sync is counted in zero data bits, so a run no longer than two bytes' worth is short enough
412
+ // to be worth logging.
413
+ const ShortSyncZeros = 16;
414
+
358
415
  class Track {
359
416
  constructor(upper, trackNum, initialByte) {
360
417
  this.length = IbmDiscFormat.bytesPerTrack; // Default size, will be updated when track is populated
@@ -371,10 +428,11 @@ class Track {
371
428
 
372
429
  /**
373
430
  * Debug functionality to try and interpret the track.
431
+ * @param {function(string): void} [warn] where to report anomalies; the console by default
374
432
  * @returns {Sector[]}
375
433
  */
376
- findSectors() {
377
- const sectors = this.findSectorIds();
434
+ findSectors(warn = console.log) {
435
+ const sectors = this.findSectorIds(warn);
378
436
  for (let sectorIndex = 0; sectorIndex !== sectors.length; ++sectorIndex) {
379
437
  const nextSector = sectors[sectorIndex + 1]; // Will be unset for last
380
438
  sectors[sectorIndex].read(nextSector);
@@ -383,9 +441,10 @@ class Track {
383
441
  }
384
442
 
385
443
  /**
444
+ * @param {function(string): void} [warn] where to report anomalies; the console by default
386
445
  * @returns {Sector[]}
387
446
  */
388
- findSectorIds() {
447
+ findSectorIds(warn = console.log) {
389
448
  const sectors = [];
390
449
  // Pass 1: walk the track and find header and data markers.
391
450
  const bitLength = this.length * 32;
@@ -394,40 +453,30 @@ class Track {
394
453
  let doMfmMarkerByte = false;
395
454
  let isMfm = false;
396
455
  let pulses = 0;
397
- let markDetector = 0n;
398
- let markDetectorPrev = 0n;
399
- const all64b = 0xffffffffffffffffn;
400
- const top32of64b = 0xffffffff00000000n;
401
- const fmMarker = 0x8888888800000000n;
402
- const mfmMarker = 0xaaaa448944894489n;
456
+ // The mark detector is a 64 bit sliding window over the pulse stream; the bits leaving it
457
+ // spill into a second window, which the sync run length is counted from.
458
+ const markDetector = new BitWindow64();
459
+ const markDetectorPrev = new BitWindow64();
403
460
  let dataByte;
404
461
  let sector = null;
405
462
  for (let pulseIndex = 0; pulseIndex < bitLength; ++pulseIndex) {
406
463
  if ((pulseIndex & 31) === 0) pulses = this.pulses2Us[pulseIndex >>> 5];
407
- markDetectorPrev = (markDetectorPrev << 1n) & all64b;
408
- markDetectorPrev |= markDetector >> 63n;
409
- markDetector = (markDetector << 1n) & all64b;
410
- shiftRegister = (shiftRegister << 1) & 0xffffffff;
464
+ const pulseBit = pulses >>> 31;
465
+ markDetectorPrev.shiftIn(markDetector.shiftIn(pulseBit));
466
+ shiftRegister = ((shiftRegister << 1) | pulseBit) & 0xffffffff;
411
467
  numShifts++;
412
- if (pulses & 0x80000000) {
413
- markDetector |= 1n;
414
- shiftRegister |= 1;
415
- }
416
468
  pulses = (pulses << 1) & 0xffffffff;
417
- if ((markDetector & top32of64b) === fmMarker) {
418
- const { clocks, data, iffyPulses } = IbmDiscFormat._2usPulsesToFm(Number(markDetector & 0xffffffffn));
469
+ if (markDetector.hi === FmSyncHi) {
470
+ const { clocks, data, iffyPulses } = IbmDiscFormat._2usPulsesToFm(markDetector.lo);
419
471
  if (iffyPulses || clocks !== IbmDiscFormat.markClockPattern) continue;
420
472
  isMfm = false;
421
473
  doMfmMarkerByte = false;
422
- let num0s = 8;
423
- for (let bits = markDetectorPrev; (bits & 0xfn) === 0x8n; bits >>= 4n) {
424
- num0s++;
425
- }
426
- if (num0s <= 16) {
427
- console.log(`Short zeros sync ${this.description}`);
474
+ const num0s = FmSyncHiZeroBits + markDetectorPrev.countTrailingNibbles(FmZeroBitPulses);
475
+ if (num0s <= ShortSyncZeros) {
476
+ warn(`Short zeros sync ${this.description}`);
428
477
  }
429
478
  dataByte = data;
430
- } else if (markDetector === mfmMarker) {
479
+ } else if (markDetector.equals(MfmMarkerHi, MfmMarkerLo)) {
431
480
  // Next byte is MFM marker.
432
481
  isMfm = true;
433
482
  doMfmMarkerByte = true;
@@ -442,7 +491,7 @@ class Track {
442
491
  }
443
492
  switch (dataByte) {
444
493
  case IbmDiscFormat.idMarkDataPattern: {
445
- sector = new Sector(this, isMfm, pulseIndex + 1);
494
+ sector = new Sector(this, isMfm, pulseIndex + 1, warn);
446
495
  sectors.push(sector);
447
496
  shiftRegister = 0;
448
497
  numShifts = 0;
@@ -451,7 +500,7 @@ class Track {
451
500
  case IbmDiscFormat.dataMarkDataPattern:
452
501
  case IbmDiscFormat.deletedDataMarkDataPattern:
453
502
  if (!sector || sector.dataPosBitOffset) {
454
- console.log(
503
+ warn(
455
504
  `Sector data without header ${this.description}; mark bitpos ${pulseIndex}; previous good sector ${sector ? sector.description : "none"}`,
456
505
  );
457
506
  } else {
@@ -464,7 +513,7 @@ class Track {
464
513
  }
465
514
  break;
466
515
  default:
467
- console.log(`Unknown marker byte ${hexbyte(dataByte)} ${this.description}`);
516
+ warn(`Unknown marker byte ${hexbyte(dataByte)} ${this.description}`);
468
517
  }
469
518
  }
470
519
  return sectors;
@@ -580,16 +629,14 @@ export function loadSsd(disc, data, isDsd, onChange) {
580
629
  // Create a dataCopy large enough for all the sectors and tracks.
581
630
  const dataCopy = new Uint8Array(maxSize);
582
631
  dataCopy.set(data);
583
- disc.setWriteTrackCallback(
632
+ disc.addTrackWriteListener(
584
633
  /** @param {Track} trackObj */
585
634
  (side, trackNum, trackObj) => {
586
635
  const trackOffset =
587
636
  SsdFormat.sectorSize * SsdFormat.sectorsPerTrack * (trackNum * numSides + (side ? 1 : 0));
588
- for (const sector of trackObj.findSectors()) {
589
- const sectorOffset = sector.sectorNumber * SsdFormat.sectorSize;
590
- for (let x = 0; x < SsdFormat.sectorSize; ++x)
591
- dataCopy[trackOffset + sectorOffset + x] = sector.sectorData[x];
592
- }
637
+ for (const sector of trackObj.findSectors())
638
+ if (!sectorShortfall(sector, trackNum))
639
+ dataCopy.set(sector.sectorData, trackOffset + sector.sectorNumber * SsdFormat.sectorSize);
593
640
  onChange(dataCopy);
594
641
  },
595
642
  );
@@ -764,7 +811,7 @@ export class Disc {
764
811
  this.tracksUsed = 0;
765
812
  this.isDoubleSided = false;
766
813
 
767
- this.writeTrackCallback = undefined;
814
+ this._trackWriteListeners = new Set();
768
815
  this.isWriteable = isWriteable;
769
816
 
770
817
  // Track which tracks have been written since the last snapshot.
@@ -791,8 +838,14 @@ export class Disc {
791
838
  this.initSurface(0);
792
839
  }
793
840
 
794
- setWriteTrackCallback(callback) {
795
- this.writeTrackCallback = callback;
841
+ /** @param {function(boolean, Number, Track): void} listener called once per flushed track */
842
+ addTrackWriteListener(listener) {
843
+ this._trackWriteListeners.add(listener);
844
+ }
845
+
846
+ /** @param {function(boolean, Number, Track): void} listener */
847
+ removeTrackWriteListener(listener) {
848
+ this._trackWriteListeners.delete(listener);
796
849
  }
797
850
 
798
851
  /**
@@ -902,10 +955,9 @@ export class Disc {
902
955
  this.isDirty = false;
903
956
  this.dirtySide = -1;
904
957
  this.dirtyTrack = -1;
905
- if (!this.writeTrackCallback) return;
906
958
  const trackObj = this.getTrack(dirtySide, dirtyTrack);
907
- this.writeTrackCallback(dirtySide, dirtyTrack, trackObj);
908
959
  this.setTrackUsed(dirtySide, dirtyTrack);
960
+ for (const listener of this._trackWriteListeners) listener(dirtySide, dirtyTrack, trackObj);
909
961
  }
910
962
 
911
963
  /**
package/src/econet.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // Code ported from Beebem (C to .js) by Jason Robson
2
2
  // The majority of the commentary here is also from Beebem
3
3
 
4
+ // How long a four-way handshake may stall before we resend.
5
+ const RetryTimeoutSecs = 0.5;
6
+
4
7
  // Econet support classes
5
8
  class ADLC {
6
9
  constructor() {
@@ -57,10 +60,11 @@ export class ReceiveBlock {
57
60
 
58
61
  // Econet class definition
59
62
  export class Econet {
60
- constructor(stationId_) {
63
+ constructor(stationId_, cyclesPerSecond) {
61
64
  // Config parameters
62
65
  this.TIME_BETWEEN_BYTES = 128;
63
66
  this.SERVER_STATION_ID = 254;
67
+ this.retryCycles = (cyclesPerSecond * RetryTimeoutSecs) | 0;
64
68
 
65
69
  // 4-way handshake states
66
70
  this.FWH_Idle = 0;
@@ -156,7 +160,7 @@ export class Econet {
156
160
  }
157
161
 
158
162
  // Re-tries
159
- if (this.pollTotalCycles > this.wireStateEntryTimer + 1000000) {
163
+ if (this.pollTotalCycles > this.wireStateEntryTimer + this.retryCycles) {
160
164
  if (this.wireState !== this.FWH_Idle) {
161
165
  switch (this.wireState) {
162
166
  case this.FWH_RX_Scout_Received:
package/src/jsbeeb.css CHANGED
@@ -441,6 +441,132 @@ small {
441
441
  pointer-events: none;
442
442
  }
443
443
 
444
+ #disc-panel {
445
+ position: fixed;
446
+ top: 56px;
447
+ right: 8px;
448
+ width: min(420px, 46vw);
449
+ max-height: calc(100vh - 104px);
450
+ overflow-y: auto;
451
+ background: rgba(0, 0, 0, 0.9);
452
+ border: 1px solid #555;
453
+ border-radius: 4px;
454
+ z-index: 10;
455
+ padding: 8px;
456
+ }
457
+
458
+ .disc-header {
459
+ display: flex;
460
+ align-items: baseline;
461
+ gap: 8px;
462
+ margin-bottom: 8px;
463
+ cursor: move;
464
+ user-select: none;
465
+ /* Claim touch gestures, or a drag scrolls the page instead. */
466
+ touch-action: none;
467
+ }
468
+
469
+ .disc-controls {
470
+ display: flex;
471
+ flex-wrap: wrap;
472
+ gap: 6px;
473
+ margin-bottom: 8px;
474
+ }
475
+
476
+ .disc-header button {
477
+ cursor: pointer;
478
+ }
479
+
480
+ .disc-title {
481
+ color: #ccc;
482
+ font-size: 12px;
483
+ font-weight: bold;
484
+ flex: none;
485
+ }
486
+
487
+ /* The only item in the header with no bound on its width, so it is the one that yields. */
488
+ #disc-name {
489
+ flex: 1;
490
+ min-width: 0;
491
+ overflow: hidden;
492
+ text-overflow: ellipsis;
493
+ white-space: nowrap;
494
+ color: #888;
495
+ font-family: consolas, monospace;
496
+ font-size: 11px;
497
+ }
498
+
499
+ #disc-close {
500
+ flex: none;
501
+ margin-left: auto;
502
+ }
503
+
504
+ .disc-stack {
505
+ position: relative;
506
+ }
507
+
508
+ .disc-stack canvas {
509
+ display: block;
510
+ width: 100%;
511
+ aspect-ratio: 1;
512
+ }
513
+
514
+ #disc-overlay {
515
+ position: absolute;
516
+ inset: 0;
517
+ cursor: crosshair;
518
+ /* Claim wheel and drag gestures, or the page scrolls instead. */
519
+ touch-action: none;
520
+ }
521
+
522
+ .disc-legend {
523
+ display: flex;
524
+ align-items: center;
525
+ flex-wrap: wrap;
526
+ gap: 4px 8px;
527
+ margin-top: 8px;
528
+ color: #aaa;
529
+ font-size: 10px;
530
+ }
531
+
532
+ .disc-legend-item {
533
+ display: inline-flex;
534
+ align-items: center;
535
+ gap: 4px;
536
+ white-space: nowrap;
537
+ }
538
+
539
+ .disc-legend-grow {
540
+ flex: 1;
541
+ min-width: 150px;
542
+ }
543
+
544
+ .disc-legend-ramp {
545
+ flex: 1;
546
+ min-width: 40px;
547
+ height: 8px;
548
+ border-radius: 2px;
549
+ }
550
+
551
+ .disc-legend-swatch {
552
+ width: 12px;
553
+ height: 8px;
554
+ border-radius: 2px;
555
+ flex: none;
556
+ }
557
+
558
+ .disc-status {
559
+ margin-top: 4px;
560
+ color: #ccc;
561
+ font-family: consolas, monospace;
562
+ font-size: 11px;
563
+ /* Rewritten every frame, so the height must not depend on the content. */
564
+ min-height: 14px;
565
+ overflow: hidden;
566
+ text-overflow: ellipsis;
567
+ white-space: nowrap;
568
+ }
569
+
444
570
  div.smoothie-chart-tooltip {
445
571
  background: #444;
446
572
  padding: 1em;
package/src/main.js CHANGED
@@ -44,6 +44,7 @@ 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";
47
48
  import { downloadBlob } from "./dom-utils.js";
48
49
  import {
49
50
  buildUrlFromParams,
@@ -232,9 +233,10 @@ speechOutput.enabled = !!parsedQuery.speechOutput;
232
233
  const config = new Config(
233
234
  function onChange(changed) {
234
235
  if (changed.displayMode) {
235
- 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));
236
239
  setCrtPic(displayModeFilter);
237
- swapCanvas(displayModeFilter);
238
240
  // Trigger window resize to recalculate layout with new dimensions
239
241
  window.dispatchEvent(new Event("resize"));
240
242
  }
@@ -352,7 +354,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
352
354
  });
353
355
 
354
356
  if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
355
- const cpuSpeed = model.clockMhz * 1000 * 1000;
357
+ const cpuSpeed = model.cyclesPerSecond;
356
358
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
357
359
  const MaxCyclesPerFrame = clocksPerSecond / 10;
358
360
 
@@ -386,11 +388,24 @@ if (keyMappingWarnings.length) {
386
388
  }
387
389
 
388
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
+
389
399
  const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
390
400
 
391
- if (filterClass.requiresGl() && !newCanvas.isWebGl()) {
392
- const config = filterClass.getDisplayConfig();
393
- 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
+ );
394
409
  }
395
410
 
396
411
  return newCanvas;
@@ -398,20 +413,33 @@ function createCanvasForFilter(filterClass) {
398
413
 
399
414
  let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
400
415
  function swapCanvas(newFilterClass) {
416
+ const oldCanvas = canvas;
401
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();
402
423
  video.fb32 = newCanvas.fb32;
403
424
  video.paint_ext = function paint(minx, miny, maxx, maxy) {
404
425
  frames++;
405
426
  if (frames < frameSkip) return;
406
427
  frames = 0;
407
- newCanvas.paint(minx, miny, maxx, maxy, this.frameCount);
428
+ newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
408
429
  };
409
430
  canvas = newCanvas;
410
- 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();
411
438
  window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
412
439
  }
413
440
 
414
441
  let canvas = createCanvasForFilter(displayModeFilter);
442
+ displayModeFilter = canvas.filterClass;
415
443
 
416
444
  video = new Video(
417
445
  model.isMaster,
@@ -420,7 +448,7 @@ video = new Video(
420
448
  frames++;
421
449
  if (frames < frameSkip) return;
422
450
  frames = 0;
423
- canvas.paint(minx, miny, maxx, maxy, this.frameCount);
451
+ canvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
424
452
  },
425
453
  { isAtom: model.isAtom },
426
454
  );
@@ -533,7 +561,7 @@ pastetext.addEventListener("drop", async function (event) {
533
561
  await loadStateFromFile(file, arrayBuffer);
534
562
  } else if (file.name.toLowerCase().endsWith(".uef")) {
535
563
  // Regular UEF tape image (not a BeebEm save state)
536
- setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model.isAtom));
564
+ setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
537
565
  } else {
538
566
  await loadHTMLFile(file);
539
567
  }
@@ -619,7 +647,7 @@ window.addEventListener("beforeunload", function (event) {
619
647
  });
620
648
 
621
649
  if (config.hasEconet) {
622
- econet = new Econet(stationId);
650
+ econet = new Econet(stationId, model.cyclesPerSecond);
623
651
  } else {
624
652
  document.getElementById("fsmenuitem").style.display = "none";
625
653
  }
@@ -1207,17 +1235,16 @@ async function loadTapeImage(tapeImage) {
1207
1235
  const split = splitImage(tapeImage);
1208
1236
  tapeImage = split.image;
1209
1237
  const schema = split.schema;
1210
- const isAtom = model.isAtom;
1211
1238
 
1212
1239
  switch (schema) {
1213
1240
  case "|":
1214
1241
  case "sth":
1215
- return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), isAtom);
1242
+ return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), model);
1216
1243
 
1217
1244
  case "data": {
1218
1245
  const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
1219
1246
  const { name, data } = await utils.unzipDiscImage(arr);
1220
- return await loadTapeFromData(name, data, isAtom);
1247
+ return await loadTapeFromData(name, data, model);
1221
1248
  }
1222
1249
 
1223
1250
  case "http":
@@ -1232,7 +1259,7 @@ async function loadTapeImage(tapeImage) {
1232
1259
  tapeData = unzipped.data;
1233
1260
  tapeImage = unzipped.name;
1234
1261
  }
1235
- return await loadTapeFromData(tapeImage, tapeData, isAtom);
1262
+ return await loadTapeFromData(tapeImage, tapeData, model);
1236
1263
  }
1237
1264
 
1238
1265
  default: {
@@ -1244,7 +1271,7 @@ async function loadTapeImage(tapeImage) {
1244
1271
  tapeData = unzipped.data;
1245
1272
  tapeName = unzipped.name;
1246
1273
  }
1247
- return await loadTapeFromData(tapeName, tapeData, isAtom);
1274
+ return await loadTapeFromData(tapeName, tapeData, model);
1248
1275
  }
1249
1276
  }
1250
1277
  }
@@ -1277,7 +1304,7 @@ document.getElementById("tape_load").addEventListener("change", async function (
1277
1304
  tapeData = unzipped.data;
1278
1305
  tapeName = unzipped.name;
1279
1306
  }
1280
- setProcessorTape(await loadTapeFromData(tapeName, tapeData, model.isAtom));
1307
+ setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1281
1308
  delete parsedQuery.tape;
1282
1309
  updateUrl();
1283
1310
  bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
@@ -1940,7 +1967,7 @@ function VirtualSpeedUpdater() {
1940
1967
  if (this.cycles) {
1941
1968
  const thisMHz = this.cycles / this.time / 1000;
1942
1969
  this.v.textContent = thisMHz.toFixed(1);
1943
- if (this.cycles >= 10 * 2 * 1000 * 1000) {
1970
+ if (this.cycles >= 10 * cpuSpeed) {
1944
1971
  this.cycles = this.time = 0;
1945
1972
  }
1946
1973
  this.header.style.color = this.speedy ? "red" : "white";
@@ -1968,6 +1995,9 @@ rewindUI = new RewindUI({
1968
1995
  });
1969
1996
  rewindUI.updateButtonState();
1970
1997
 
1998
+ if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
1999
+ else document.getElementById("disc-visualiser-open").classList.add("disabled");
2000
+
1971
2001
  function draw(now) {
1972
2002
  if (!running) {
1973
2003
  last = 0;
@@ -2083,6 +2113,9 @@ function stop(debug) {
2083
2113
  updateDebugButtons();
2084
2114
  }
2085
2115
 
2116
+ /** Steps the drawing buffer grows in, as a multiple of the base canvas size. */
2117
+ const CanvasScaleStep = 0.25;
2118
+
2086
2119
  (function () {
2087
2120
  const resizeCubMonitor = document.getElementById("cub-monitor");
2088
2121
  const resizeCubMonitorPic = document.getElementById("cub-monitor-pic");
@@ -2135,6 +2168,25 @@ function stop(debug) {
2135
2168
  resizeCubMonitor.style.width = width + "px";
2136
2169
  resizeCubMonitorPic.style.height = height + "px";
2137
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
+
2138
2190
  screenCanvas.style.width = finalCanvasWidth + "px";
2139
2191
  screenCanvas.style.height = finalCanvasHeight + "px";
2140
2192
  screenCanvas.style.left = canvasOrigLeft * containerScale + "px";
package/src/models.js CHANGED
@@ -35,6 +35,15 @@ class Model {
35
35
  this.cmosOverride = cmosOverride;
36
36
  }
37
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
+
38
47
  get nmos() {
39
48
  return this._cpuModel === CpuModel.MOS6502;
40
49
  }
@@ -225,8 +234,8 @@ export const allModels = [
225
234
  name: "Tube65C02",
226
235
  synonyms: [],
227
236
  os: ["tube/6502Tube.rom"],
228
- // TODO(#746): the external second processor was an NMOS 6502A.
229
- cpuModel: CpuModel.CMOS65C02,
237
+ // The production wedge's GTE 65SC02 has no Rockwell bit instructions.
238
+ cpuModel: CpuModel.CMOS65C12,
230
239
  isMaster: false,
231
240
  clockMhz: 3,
232
241
  }),
@@ -234,7 +243,8 @@ export const allModels = [
234
243
  name: "Tube65C102",
235
244
  synonyms: [],
236
245
  os: ["tube/65C102Tube.rom"],
237
- // TODO(#746): Acorn's 65C102 has no Rockwell bit instructions.
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.
238
248
  cpuModel: CpuModel.CMOS65C02,
239
249
  isMaster: false,
240
250
  clockMhz: 4,