jsbeeb 1.15.0 → 1.17.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/video.js CHANGED
@@ -3,6 +3,7 @@ import { Teletext } from "./teletext.js";
3
3
  import * as utils from "./utils.js";
4
4
  import { BbcDefaultPalette as NulaDefaultPalette } from "./bbc-palette.js";
5
5
  import { Video6847 } from "./6847.js";
6
+ import { encodeLineGrid, texelsPerPixel, LineGridRows } from "./video-filters/pixel-grid.js";
6
7
 
7
8
  export const VDISPENABLE = 1 << 0;
8
9
  export const HDISPENABLE = 1 << 1;
@@ -16,6 +17,8 @@ export const EVERYTHINGENABLED =
16
17
  export const OPAQUE_BLACK = 0xff000000;
17
18
  export const OPAQUE_WHITE = 0xffffffff;
18
19
 
20
+ export const MinPaintedFrameRows = 64;
21
+
19
22
  ////////////////////
20
23
  // VideoNULA - programmable 12-bit RGB palette extension (RobC hardware mod).
21
24
  // Reference: b-em src/video.c (stardot/b-em).
@@ -127,6 +130,7 @@ class Ula {
127
130
  this.video.ulaMode = newMode;
128
131
  }
129
132
  this.video.teletextMode = !!(val & 2);
133
+ this.video.updateLineGridUla();
130
134
  }
131
135
 
132
136
  // ULA palette register (&FE21).
@@ -386,6 +390,17 @@ export class Video {
386
390
  this.doubledScanlines = true;
387
391
  this.frameSkipCount = 0;
388
392
  this.screenSubtract = 0;
393
+ // Describes the logical pixel grid of each framebuffer row: how many
394
+ // texels wide and tall one BBC pixel is on that line. Display filters
395
+ // need this to see the picture as pixels rather than as raster samples;
396
+ // see video-filters/pixel-grid.js. One byte per row, written as each
397
+ // character cell renders, so a mode change between rows is recorded
398
+ // faithfully. A row whose mode changes part way along keeps only the
399
+ // last mode on it — enough for a raster split, not for a mid-line one.
400
+ this.lineGrid = new Uint8Array(LineGridRows);
401
+ this.lineGridUla = 0;
402
+ this.lineGridUlaDoubled = 0;
403
+ this.updateLineGridUla();
389
404
 
390
405
  this.topBorder = 12;
391
406
  this.bottomBorder = 13;
@@ -524,6 +539,8 @@ export class Video {
524
539
  this.halfClock = state.halfClock;
525
540
  this.ulaMode = state.ulaMode;
526
541
  this.teletextMode = state.teletextMode;
542
+ // Derived from the above, so it is recomputed rather than snapshotted.
543
+ this.updateLineGridUla();
527
544
  this.displayEnableSkew = state.displayEnableSkew;
528
545
  this.actualPal.set(state.actualPal);
529
546
  this.cursorOn = state.cursorOn;
@@ -555,20 +572,24 @@ export class Video {
555
572
 
556
573
  clearPaintBuffer() {
557
574
  const fb32 = this.fb32;
575
+ // The line grid is cleared exactly where the pixels are: in interlaced
576
+ // modes the other field's rows survive, and so must their grid.
558
577
  if (this.interlacedSyncAndVideo || !this.doubledScanlines) {
559
578
  let line = this.frameCount & 1;
560
579
  while (line < 625) {
561
580
  const start = line * 1024;
562
581
  fb32.fill(OPAQUE_BLACK, start, start + 1024);
582
+ this.lineGrid[line] = 0;
563
583
  line += 2;
564
584
  }
565
585
  } else {
566
586
  fb32.fill(OPAQUE_BLACK);
587
+ this.lineGrid.fill(0);
567
588
  }
568
589
  }
569
590
 
570
- paintAndClear() {
571
- if (this.dispEnabled & FRAMESKIPENABLE) {
591
+ flyback() {
592
+ if (this.bitmapY >= MinPaintedFrameRows && this.dispEnabled & FRAMESKIPENABLE) {
572
593
  this.paint();
573
594
  this.clearPaintBuffer();
574
595
  }
@@ -610,6 +631,21 @@ export class Video {
610
631
  debugCopyFb(this.fb32, this.debugPrevScreen);
611
632
  }
612
633
 
634
+ /**
635
+ * Recompute the ULA-dependent half of the line grid descriptor: everything
636
+ * except whether this particular scanline was doubled. Called whenever the
637
+ * ULA control register changes, so the render loop only has to store it.
638
+ *
639
+ * MODE 7 counts as one texel per pixel: the SAA5050 emulation writes each
640
+ * of its 16 texels per character individually, so its output is already at
641
+ * the framebuffer's own resolution.
642
+ */
643
+ updateLineGridUla() {
644
+ const texelsWide = this.teletextMode ? 1 : texelsPerPixel(this.ulaMode);
645
+ this.lineGridUla = encodeLineGrid(texelsWide, false);
646
+ this.lineGridUlaDoubled = encodeLineGrid(texelsWide, true);
647
+ }
648
+
613
649
  blitFb(dat, destOffset, numPixels) {
614
650
  destOffset |= 0;
615
651
  const offset = table4bppOffset(this.ulaMode, dat);
@@ -816,14 +852,12 @@ export class Video {
816
852
  // an approximation that works if hsyncs are spaced evenly.
817
853
  this.bitmapY += 2;
818
854
 
819
- // If no VSync occurs this frame, go back to the top and force a repaint
820
- if (this.bitmapY >= 768) {
821
- // Arbitrary moment when TV will give up and start flyback in the absence of an explicit VSync signal
822
- this.paintAndClear();
823
- }
855
+ // Arbitrary moment when TV will give up and start flyback in the absence of an explicit VSync signal
856
+ return this.bitmapY >= 768;
824
857
  } else if (this.hpulseCounter === (this.regs[3] & 0x0f)) {
825
858
  this.inHSync = false;
826
859
  }
860
+ return false;
827
861
  }
828
862
 
829
863
  cb2changed(level, output) {
@@ -871,7 +905,7 @@ export class Video {
871
905
  // This emulates the Hitachi 6845SP CRTC.
872
906
  // Other variants have different quirks.
873
907
  // Handle HSync
874
- if (this.inHSync) this.handleHSync();
908
+ if (this.inHSync && this.handleHSync()) this.flyback();
875
909
 
876
910
  // Handle delayed display enable due to skew
877
911
  const displayEnablePos = this.displayEnableSkew + (this.teletextMode ? 2 : 0);
@@ -935,11 +969,7 @@ export class Video {
935
969
  this.hadVSyncThisRow = true;
936
970
  this.vpulseCounter = 0;
937
971
 
938
- // Avoid intense painting if registers have boot-up or
939
- // otherwise small values.
940
- if (this.regs[0] && this.regs[4]) {
941
- this.paintAndClear();
942
- }
972
+ this.flyback();
943
973
  }
944
974
 
945
975
  if (vSyncStarting || vSyncEnding) {
@@ -974,7 +1004,7 @@ export class Video {
974
1004
  // Render data depending on display enable state.
975
1005
  if (this.bitmapX >= 0 && this.bitmapX < 1024 && this.bitmapY < 625) {
976
1006
  let doubledLines = false;
977
- let offset = this.bitmapY;
1007
+ let bitmapRow = this.bitmapY;
978
1008
  // There's a painting subtlety here: if we're in an
979
1009
  // interlace mode but R6>R4 then we'll get stuck
980
1010
  // painting just an odd or even frame, so we double up
@@ -984,12 +1014,22 @@ export class Video {
984
1014
  this.isEvenRender === this.lastRenderWasEven
985
1015
  ) {
986
1016
  doubledLines = true;
987
- offset &= ~1;
1017
+ bitmapRow &= ~1;
988
1018
  }
989
1019
 
990
- offset = offset * 1024 + this.bitmapX;
1020
+ const offset = bitmapRow * 1024 + this.bitmapX;
991
1021
 
992
1022
  if ((this.dispEnabled & EVERYTHINGENABLED) === EVERYTHINGENABLED) {
1023
+ // Note this row's logical pixel size for display
1024
+ // filters; see video-filters/pixel-grid.js. The ULA half
1025
+ // of the descriptor is precomputed on register writes so
1026
+ // this stays a store or two in the hottest loop we have.
1027
+ if (doubledLines) {
1028
+ this.lineGrid[bitmapRow] = this.lineGridUlaDoubled;
1029
+ this.lineGrid[bitmapRow + 1] = this.lineGridUlaDoubled;
1030
+ } else {
1031
+ this.lineGrid[bitmapRow] = this.lineGridUla;
1032
+ }
993
1033
  if (this.teletextMode) {
994
1034
  if (this.halfClock) {
995
1035
  // Proper MODE 7 (1MHz clock + teletext): render SAA5050 output normally.
package/src/wd-fdc.js CHANGED
@@ -46,6 +46,17 @@ const CommandBits = Object.freeze({
46
46
  typeIIDeleted: 0x01,
47
47
  });
48
48
 
49
+ /**
50
+ * Type IV (force interrupt) condition bits, taken from the command's low nibble.
51
+ *
52
+ * @readonly
53
+ * @enum {Number}
54
+ */
55
+ const ForceInterruptBits = Object.freeze({
56
+ indexPulse: 0x04,
57
+ immediate: 0x08,
58
+ });
59
+
49
60
  /**
50
61
  * The drive control register is documented here:
51
62
  * https://www.cloud9.co.uk/james/BBCMicro/Documentation/wd1770.html
@@ -512,6 +523,8 @@ export class WdFdc {
512
523
  // insofar as index pulse appears to be reported in the status register.
513
524
  // - Interrupt on index pulse is only active for the current command.
514
525
  if (this._statusRegister & Status.busy) {
526
+ // Any pending timer belongs to the command being aborted.
527
+ this._clearTimer();
515
528
  this._commandDone(false);
516
529
  } else {
517
530
  if (this._state !== State.idle) throw new Error(`Unexpected state when force interrupt: ${this._state}`);
@@ -523,13 +536,10 @@ export class WdFdc {
523
536
  this._currentDrive.startSpinning();
524
537
  }
525
538
  }
526
- if (forceInterruptBits === 0) {
527
- this._isInterruptOnIndexPulse = false;
528
- } else if (forceInterruptBits === 4) {
529
- this._isInterruptOnIndexPulse = true;
530
- } else {
531
- throw new Error(`1700 force interrupt flags not handled: ${forceInterruptBits}`);
532
- }
539
+ if (forceInterruptBits & ForceInterruptBits.immediate) this._setIntRq(true);
540
+ this._isInterruptOnIndexPulse = !!(forceInterruptBits & ForceInterruptBits.indexPulse);
541
+ // The remaining two bits select interrupts on the ready line's transitions. The BBC ties
542
+ // the 1770's READY input active, so neither transition can ever occur.
533
543
  }
534
544
 
535
545
  _timerFired() {
@@ -1404,8 +1414,15 @@ export class WdFdc {
1404
1414
  * @param {Number} drive
1405
1415
  * @param {Disc} disc
1406
1416
  */
1407
- loadDisc(drive, disc) {
1417
+ /**
1418
+ * @param {Number} drive
1419
+ * @param {Disc} disc
1420
+ * @param {Number} [tracksPerStep] where to leave the drive's 40/80 switch, which by default
1421
+ * follows the disc, since no drive can tell what pitch the disc in it was written at
1422
+ */
1423
+ loadDisc(drive, disc, tracksPerStep = disc?.is40Track ? 2 : 1) {
1408
1424
  this._drives[drive].setDisc(disc);
1425
+ this._drives[drive].tracksPerStep = tracksPerStep;
1409
1426
  }
1410
1427
 
1411
1428
  get motorOn() {
@@ -15,6 +15,7 @@ export class AudioHandler {
15
15
  this.cpuSpeed = cpuSpeed;
16
16
  this.isAtom = isAtom;
17
17
  this.warningNode = warningNode;
18
+ this.noAudioWorklet = false;
18
19
  toggle(this.warningNode, false);
19
20
  this.stats = {};
20
21
  if (statsNode) {
@@ -41,14 +42,15 @@ export class AudioHandler {
41
42
  } else {
42
43
  if (this.audioContext && !this.audioContext.audioWorklet) {
43
44
  this.audioContext = null;
45
+ this.noAudioWorklet = true;
44
46
  console.log("Unable to initialise audio: no audio worklet API");
45
- toggle(this.warningNode, true);
46
47
  const localhost = new URL(window.location);
47
48
  localhost.hostname = "localhost";
48
49
  this.warningNode.innerHTML = `No audio worklet API was found - there will be no audio.
49
50
  If you are running a local jsbeeb, you must either use a host of
50
51
  <a href="${localhost}">localhost</a>,
51
52
  or serve the content over <em>https</em>.`;
53
+ toggle(this.warningNode, true);
52
54
  }
53
55
  this.soundChip = new FakeSoundChip();
54
56
  this.ddNoise = new FakeDdNoise();
@@ -56,7 +58,6 @@ export class AudioHandler {
56
58
  }
57
59
 
58
60
  this.warningNode.addEventListener("mousedown", () => this.tryResume());
59
- toggle(this.warningNode, false);
60
61
 
61
62
  // Initialise Music 5000 audio context
62
63
  this.audioContextM5000 = createAudioContext({ sampleRate: 46875 });
@@ -145,6 +146,7 @@ export class AudioHandler {
145
146
  }
146
147
 
147
148
  checkStatus() {
149
+ if (this.noAudioWorklet) return;
148
150
  if (!this.audioContext && !this.audioContextM5000) return;
149
151
  const suspended =
150
152
  (this.audioContext && this.audioContext.state === "suspended") ||
@@ -0,0 +1,79 @@
1
+ import * as bootstrap from "bootstrap";
2
+
3
+ /**
4
+ * Passing notices: something happened that is worth knowing and needs nothing doing about it.
5
+ * The error dialog is for the other kind.
6
+ */
7
+
8
+ let nextQuietId = 0;
9
+
10
+ function toastContainer() {
11
+ const existing = document.querySelector(".toast-container");
12
+ if (existing) return existing;
13
+ const container = document.createElement("div");
14
+ container.className = "toast-container position-fixed bottom-0 end-0 p-3";
15
+ document.body.appendChild(container);
16
+ return container;
17
+ }
18
+
19
+ // Storage can be unreachable or full, and a notice that nothing needs doing about is not worth
20
+ // failing over: forget the answer and say the thing again.
21
+ function remembered(key) {
22
+ try {
23
+ return !!window.localStorage.getItem(key);
24
+ } catch (e) {
25
+ console.log(`Unable to read ${key}: ${e}`);
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function remember(key, wanted) {
31
+ try {
32
+ if (wanted) window.localStorage.setItem(key, "yes");
33
+ else window.localStorage.removeItem(key);
34
+ } catch (e) {
35
+ console.log(`Unable to remember ${key}: ${e}`);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param {string} message
41
+ * @param {object} [options]
42
+ * @param {string} [options.title] a heading, for a notice whose message does not say where it came from
43
+ * @param {string} [options.quietKey] offer to stop showing this kind of notice, remembering the answer here
44
+ */
45
+ export function toast(message, { title = "", quietKey = "" } = {}) {
46
+ if (quietKey && remembered(quietKey)) return;
47
+
48
+ const element = document.createElement("div");
49
+ element.className = "toast text-bg-dark";
50
+ element.setAttribute("role", "status");
51
+ element.setAttribute("aria-live", "polite");
52
+ element.setAttribute("aria-atomic", "true");
53
+ const quietId = `toast-quiet-${nextQuietId++}`;
54
+ element.innerHTML = `
55
+ <div class="toast-header text-bg-dark">
56
+ <strong class="me-auto"></strong>
57
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
58
+ </div>
59
+ <div class="toast-body">
60
+ <div class="message"></div>
61
+ <div class="form-check mt-2">
62
+ <input class="form-check-input" type="checkbox" id="${quietId}" />
63
+ <label class="form-check-label small" for="${quietId}">Stop telling me this</label>
64
+ </div>
65
+ </div>`;
66
+ // Disc names and the like come from the outside world, so they are set as text, never as markup.
67
+ element.querySelector(".message").textContent = message;
68
+ element.querySelector(".toast-header strong").textContent = title;
69
+ element.querySelector(".toast-header").hidden = !title;
70
+
71
+ const quiet = element.querySelector(".form-check");
72
+ quiet.hidden = !quietKey;
73
+ quiet.querySelector("input").addEventListener("change", (event) => remember(quietKey, event.target.checked));
74
+
75
+ toastContainer().appendChild(element);
76
+ element.addEventListener("hidden.bs.toast", () => element.remove());
77
+ bootstrap.Toast.getOrCreateInstance(element).show();
78
+ return element;
79
+ }
@@ -178,7 +178,7 @@ export class TestMachine {
178
178
  return true;
179
179
  }
180
180
  });
181
- await this.runFor(secs * 1 * 1000 * 1000); // Atom is 1 MHz
181
+ await this.runFor(secs * this.model.cyclesPerSecond);
182
182
  hook.remove();
183
183
  assert(hit, "Atom did not reach keyboard input in time");
184
184
  return this.runFor(10 * 1000);
@@ -191,7 +191,7 @@ export class TestMachine {
191
191
  return true;
192
192
  }
193
193
  });
194
- await this.runFor(secs * 2 * 1000 * 1000);
194
+ await this.runFor(secs * this.model.cyclesPerSecond);
195
195
  hook.remove();
196
196
  assert(hit, "did not hit appropriate breakpoint in time");
197
197
  return this.runFor(10 * 1000);
@@ -206,14 +206,14 @@ export class TestMachine {
206
206
  return true;
207
207
  }
208
208
  });
209
- await this.runFor(secs * 2 * 1000 * 1000);
209
+ await this.runFor(secs * this.model.cyclesPerSecond);
210
210
  hook.remove();
211
211
  assert(hit, "did not hit appropriate breakpoint in time");
212
212
  }
213
213
 
214
214
  async loadDisc(image) {
215
215
  const data = await fdc.load(image);
216
- this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, "", data));
216
+ this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, image, data));
217
217
  }
218
218
 
219
219
  /**
@@ -370,7 +370,8 @@ export class TestMachine {
370
370
  let nextEventCycle = 0;
371
371
  let done = false;
372
372
 
373
- const currentCycle = () => this.processor.cycleSeconds * 2000000 + this.processor.currentCycles;
373
+ const currentCycle = () =>
374
+ this.processor.cycleSeconds * this.model.cyclesPerSecond + this.processor.currentCycles;
374
375
 
375
376
  const hook = this.processor.debugInstruction.add(() => {
376
377
  if (currentCycle() < nextEventCycle) return;
@@ -425,7 +426,8 @@ export class TestMachine {
425
426
  let done = false;
426
427
  let shiftHeld = false;
427
428
 
428
- const currentCycle = () => this.processor.cycleSeconds * 1000000 + this.processor.currentCycles;
429
+ const currentCycle = () =>
430
+ this.processor.cycleSeconds * this.model.cyclesPerSecond + this.processor.currentCycles;
429
431
 
430
432
  const isShift = (entry) => entry[0] === SHIFT[0] && entry[1] === SHIFT[1];
431
433