jsbeeb 1.19.1 → 1.19.2

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/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.19.1",
10
+ "version": "1.19.2",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
package/src/teletext.js CHANGED
@@ -16,7 +16,11 @@ export class Teletext {
16
16
  this.flashTime = 0;
17
17
  this.heldChar = 0;
18
18
  this.holdChar = false;
19
- this.dataQueue = [0, 0, 0, 0];
19
+ this.cellGlyphIndex = 0;
20
+ this.cellFlash = false;
21
+ this.cellConceal = false;
22
+ this.cellPalette = 0;
23
+ this.dataQueue = new Uint8Array(4);
20
24
  this.scanlineCounter = 0;
21
25
  this.levelDEW = false;
22
26
  this.levelDISPTMG = false;
@@ -204,7 +208,7 @@ export class Teletext {
204
208
  this.flashTime = state.flashTime;
205
209
  this.heldChar = state.heldChar;
206
210
  this.holdChar = state.holdChar;
207
- this.dataQueue = [...state.dataQueue];
211
+ this.dataQueue.set(state.dataQueue);
208
212
  this.scanlineCounter = state.scanlineCounter;
209
213
  this.levelDEW = state.levelDEW;
210
214
  this.levelDISPTMG = state.levelDISPTMG;
@@ -308,8 +312,11 @@ export class Teletext {
308
312
  }
309
313
 
310
314
  fetchData(data) {
311
- this.dataQueue.shift();
312
- this.dataQueue.push(data & 0x7f);
315
+ // `copyWithin` over four bytes costs more than the moves it saves.
316
+ this.dataQueue[0] = this.dataQueue[1];
317
+ this.dataQueue[1] = this.dataQueue[2];
318
+ this.dataQueue[2] = this.dataQueue[3];
319
+ this.dataQueue[3] = data & 0x7f;
313
320
  }
314
321
 
315
322
  setDEW(level) {
@@ -386,14 +393,13 @@ export class Teletext {
386
393
  this.levelRA0 = level;
387
394
  }
388
395
 
389
- render(buf, offset) {
396
+ /**
397
+ * Clock one character through the chip: take the oldest byte in the pipeline, apply it
398
+ * to the character state, and latch how the resulting cell should look.
399
+ */
400
+ advance() {
390
401
  let data = this.dataQueue[0];
391
402
 
392
- let scanline = this.scanlineCounter << 1;
393
- if (this.levelRA0) {
394
- scanline++;
395
- }
396
-
397
403
  this.oldDbl = this.dbl;
398
404
 
399
405
  this.prevCol = this.col;
@@ -412,28 +418,49 @@ export class Teletext {
412
418
  this.heldChar = 32;
413
419
  }
414
420
 
421
+ // Flash (code 8) is "Set After" — flashThisCell retains the pre-control-code state.
422
+ // Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
423
+ if (flashThisCell && !this.flash) flashThisCell = false;
424
+
425
+ // Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
426
+ if (this.conceal) concealThisCell = true;
427
+
428
+ this.cellGlyphIndex = (data - 32) * 20;
429
+ this.cellFlash = flashThisCell;
430
+ this.cellConceal = concealThisCell;
431
+ this.cellPalette = ((this.bg & 7) << 5) | ((this.prevCol & 7) << 2);
432
+ }
433
+
434
+ glyphScanline() {
435
+ let scanline = this.scanlineCounter << 1;
436
+ if (this.levelRA0) {
437
+ scanline++;
438
+ }
439
+
415
440
  if (this.oldDbl) {
416
441
  scanline = scanline >>> 1;
417
442
  if (this.secondHalfOfDouble) {
418
443
  scanline += 10;
419
444
  }
420
445
  }
421
- let chardef = this.curGlyphs[(data - 32) * 20 + scanline];
422
-
423
- // Flash (code 8) is "Set After" — flashThisCell retains the pre-control-code state.
424
- // Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
425
- if (flashThisCell && !this.flash) flashThisCell = false;
446
+ return scanline;
447
+ }
426
448
 
427
- // Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
428
- if (this.conceal) concealThisCell = true;
449
+ /**
450
+ * Paint the cell most recently latched by `advance`. Everything else read here changes
451
+ * per scanline or per field, not per character.
452
+ */
453
+ emit(buf, offset) {
454
+ const scanline = this.glyphScanline();
429
455
 
430
- if (concealThisCell || (flashThisCell && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
431
- const backgroundColour = this.colour[(this.bg & 7) << 5];
456
+ if (this.cellConceal || (this.cellFlash && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
457
+ const backgroundColour = this.colour[this.cellPalette & 0xe0];
432
458
  for (let i = 0; i < 16; ++i) {
433
459
  buf[offset++] = backgroundColour;
434
460
  }
435
461
  } else {
436
- const paletteIndex = ((this.bg & 7) << 5) | ((this.prevCol & 7) << 2);
462
+ let chardef = this.curGlyphs[this.cellGlyphIndex + scanline];
463
+ const paletteIndex = this.cellPalette;
437
464
 
438
465
  for (let pixel = 0; pixel < 16; ++pixel) {
439
466
  buf[offset + pixel] = this.colour[paletteIndex + (chardef & 3)];
@@ -441,4 +468,27 @@ export class Teletext {
441
468
  }
442
469
  }
443
470
  }
471
+
472
+ // The second half of the cell `emit` last painted, for the ULA's 1MHz repaint.
473
+ emitSecondHalf(buf, offset) {
474
+ const scanline = this.glyphScanline();
475
+ if (this.cellConceal || (this.cellFlash && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
476
+ const backgroundColour = this.colour[this.cellPalette & 0xe0];
477
+ for (let pixel = 8; pixel < 16; ++pixel) {
478
+ buf[offset + pixel] = backgroundColour;
479
+ }
480
+ } else {
481
+ let chardef = this.curGlyphs[this.cellGlyphIndex + scanline] >>> 16;
482
+ const paletteIndex = this.cellPalette;
483
+ for (let pixel = 8; pixel < 16; ++pixel) {
484
+ buf[offset + pixel] = this.colour[paletteIndex + (chardef & 3)];
485
+ chardef >>>= 2;
486
+ }
487
+ }
488
+ }
489
+
490
+ render(buf, offset) {
491
+ this.advance();
492
+ this.emit(buf, offset);
493
+ }
444
494
  }
package/src/video.js CHANGED
@@ -84,6 +84,7 @@ class Ula {
84
84
  this._writeNulaPalette(val);
85
85
  break;
86
86
  }
87
+ this.video.repaintSecondHalfOfCell();
87
88
  }
88
89
 
89
90
  snapshotState() {
@@ -385,7 +386,9 @@ export class Video {
385
386
  this.cursorOff = false;
386
387
  this.cursorOnThisFrame = false;
387
388
  this.cursorDrawIndex = 0;
389
+ this.cursorInvertedOffset = -1;
388
390
  this.cursorPos = 0;
391
+ this.cellData = 0;
389
392
  this.interlacedSyncAndVideo = false;
390
393
  this.doubledScanlines = true;
391
394
  this.frameSkipCount = 0;
@@ -546,6 +549,8 @@ export class Video {
546
549
  this.cursorOn = state.cursorOn;
547
550
  this.cursorOff = state.cursorOff;
548
551
  this.cursorOnThisFrame = state.cursorOnThisFrame;
552
+ this.cursorInvertedOffset = -1;
553
+ this.cellData = 0;
549
554
  this.cursorDrawIndex = state.cursorDrawIndex;
550
555
  this.cursorPos = state.cursorPos;
551
556
  this.interlacedSyncAndVideo = state.interlacedSyncAndVideo;
@@ -599,6 +604,7 @@ export class Video {
599
604
  if (this.frameCount % this.frameSkipCount) enable = 0;
600
605
  }
601
606
  this.dispEnabled |= enable;
607
+ this.cursorInvertedOffset = -1;
602
608
 
603
609
  this.bitmapY = 0;
604
610
  // Interlace even frame fires vsync midway through a scanline.
@@ -650,11 +656,7 @@ export class Video {
650
656
  destOffset |= 0;
651
657
  const offset = table4bppOffset(this.ulaMode, dat);
652
658
  const fb32 = this.fb32;
653
- // In NULA palette mode, bypass the ULA palette (ulaPal) and look up
654
- // pixel colours directly from the NULA 12-bit colour table (collook).
655
- // This skips the XOR-7 logical↔physical colour mapping that the
656
- // standard ULA applies. Reference: b-em src/video.c lines 1083, 1117.
657
- const colourLookup = this.ula.paletteMode ? this.ula.collook : this.ulaPal;
659
+ const colourLookup = this.pixelColours();
658
660
  const table4bpp = this.table4bpp;
659
661
  // Take advantage of numPixels being either 8 or 16
660
662
  if (numPixels === 8) {
@@ -668,20 +670,70 @@ export class Video {
668
670
  }
669
671
  }
670
672
 
673
+ // The second half of a 16 pixel cell, for the 1MHz repaint.
674
+ blitFbSecondHalf(dat, destOffset) {
675
+ const offset = table4bppOffset(this.ulaMode, dat);
676
+ const colourLookup = this.pixelColours();
677
+ for (let i = 8; i < 16; ++i) {
678
+ this.fb32[destOffset + i] = colourLookup[this.table4bpp[offset + i]];
679
+ }
680
+ }
681
+
682
+ // In NULA palette mode, bypass the ULA palette (ulaPal) and look up
683
+ // pixel colours directly from the NULA 12-bit colour table (collook).
684
+ // This skips the XOR-7 logical↔physical colour mapping that the
685
+ // standard ULA applies. Reference: b-em src/video.c lines 1083, 1117.
686
+ pixelColours() {
687
+ return this.ula.paletteMode ? this.ula.collook : this.ulaPal;
688
+ }
689
+
671
690
  handleCursor(offset) {
672
691
  if (this.cursorOnThisFrame && this.ulactrl & this.cursorTable[this.cursorDrawIndex]) {
673
- for (let i = 0; i < this.pixelsPerChar; ++i) {
674
- this.fb32[offset + i] ^= 0x00ffffff;
675
- }
676
- if (this.doubledScanlines && !this.interlacedSyncAndVideo) {
677
- for (let i = 0; i < this.pixelsPerChar; ++i) {
678
- this.fb32[offset + 1024 + i] ^= 0x00ffffff;
679
- }
680
- }
692
+ this.invertForCursor(offset, 0);
693
+ this.cursorInvertedOffset = offset;
694
+ } else {
695
+ this.cursorInvertedOffset = -1;
681
696
  }
682
697
  if (++this.cursorDrawIndex === 7) this.cursorDrawIndex = 0;
683
698
  }
684
699
 
700
+ invertForCursor(offset, fromPixel) {
701
+ for (let i = fromPixel; i < this.pixelsPerChar; ++i) {
702
+ this.fb32[offset + i] ^= 0x00ffffff;
703
+ }
704
+ if (this.doubledScanlines && !this.interlacedSyncAndVideo) {
705
+ for (let i = fromPixel; i < this.pixelsPerChar; ++i) {
706
+ this.fb32[offset + 1024 + i] ^= 0x00ffffff;
707
+ }
708
+ }
709
+ }
710
+
711
+ // The render loop paints a whole 1MHz cell on one 2MHz tick and skips the next, but the ULA
712
+ // output stage switches at 2MHz: a register write landing on the skipped tick changes the
713
+ // second half of the cell just painted. See https://github.com/mattgodbolt/jsbeeb/issues/766
714
+ repaintSecondHalfOfCell() {
715
+ if (!this.halfClock || !this.oddClock) return;
716
+ if ((this.dispEnabled & EVERYTHINGENABLED) !== EVERYTHINGENABLED) return;
717
+ if (this.bitmapX < 0 || this.bitmapX >= 1024 || this.bitmapY < 0 || this.bitmapY >= 625) return;
718
+ // The same line doubling decision as the render loop, which inlines it for speed.
719
+ const doubledLines =
720
+ (this.doubledScanlines && !this.interlacedSyncAndVideo) || this.isEvenRender === this.lastRenderWasEven;
721
+ const bitmapRow = doubledLines ? this.bitmapY & ~1 : this.bitmapY;
722
+ const offset = bitmapRow * 1024 + this.bitmapX;
723
+ const halfCell = this.pixelsPerChar >>> 1;
724
+ if (this.teletextMode) {
725
+ this.teletext.emitSecondHalf(this.fb32, offset);
726
+ } else {
727
+ this.blitFbSecondHalf(this.cellData, offset);
728
+ }
729
+ if (doubledLines) {
730
+ this.fb32.copyWithin(offset + 1024 + halfCell, offset + halfCell, offset + this.pixelsPerChar);
731
+ }
732
+ if (this.cursorInvertedOffset === offset) {
733
+ this.invertForCursor(offset, halfCell);
734
+ }
735
+ }
736
+
685
737
  setScreenHwScroll(viaScreenHwScroll) {
686
738
  this.screenSubtract = this.screenAddrSubtract[viaScreenHwScroll];
687
739
  }
@@ -984,11 +1036,15 @@ export class Video {
984
1036
  // Read data from address pointer if both horizontal and vertical display enabled.
985
1037
  const dat = this.readVideoMem();
986
1038
  if (insideBorder) {
987
- // Always feed the SAA5050 pipeline: on real hardware IC15
988
- // permanently connects the video bus to the SAA5050 inputs
989
- // regardless of ULA mode. Required for the "TTX trick".
1039
+ // Always clock the SAA5050, whatever the ULA mode: IC15 latches the video bus
1040
+ // into the chip and it is MA13, not the ULA's teletext bit, that gates it. We
1041
+ // do not model the MA13 gate yet, so this feeds unconditionally. The chip is
1042
+ // clocked here and painted later, so a control code seen while the ULA shows
1043
+ // bitmap still takes effect.
990
1044
  // See https://github.com/mattgodbolt/jsbeeb/issues/546
1045
+ // and https://github.com/mattgodbolt/jsbeeb/issues/832
991
1046
  this.teletext.fetchData(dat);
1047
+ this.teletext.advance();
992
1048
 
993
1049
  // Check cursor start.
994
1050
  if (
@@ -1018,6 +1074,7 @@ export class Video {
1018
1074
  }
1019
1075
 
1020
1076
  const offset = bitmapRow * 1024 + this.bitmapX;
1077
+ this.cellData = dat;
1021
1078
 
1022
1079
  if ((this.dispEnabled & EVERYTHINGENABLED) === EVERYTHINGENABLED) {
1023
1080
  // Note this row's logical pixel size for display
@@ -1033,24 +1090,24 @@ export class Video {
1033
1090
  if (this.teletextMode) {
1034
1091
  if (this.halfClock) {
1035
1092
  // Proper MODE 7 (1MHz clock + teletext): render SAA5050 output normally.
1036
- this.teletext.render(this.fb32, offset);
1093
+ this.teletext.emit(this.fb32, offset);
1037
1094
  } else {
1038
- // 2MHz clock + teletext bit set (the "TTX trick"): the Video ULA
1039
- // forces DISPEN to the SAA5050 to 0 in 2MHz modes (0/1/2/3), so
1040
- // the SAA5050 outputs black. Confirmed by Rich Talbot-Watkins (RTW)
1041
- // at ABUG 2026-03-13.
1095
+ // 2MHz clock + teletext bit set (the "TTX trick"): the SAA5050
1096
+ // outputs black. Behaviour confirmed by Rich Talbot-Watkins (RTW)
1097
+ // at ABUG 2026-03-13; the mechanism is not established, as the
1098
+ // Model B ULA has no connection to the SAA5050 at all.
1042
1099
  // See https://github.com/mattgodbolt/jsbeeb/issues/546
1043
1100
  this.fb32.fill(OPAQUE_BLACK, offset, offset + this.pixelsPerChar);
1044
1101
  }
1045
1102
  } else {
1046
- this.blitFb(dat, offset, this.pixelsPerChar, doubledLines);
1103
+ this.blitFb(dat, offset, this.pixelsPerChar);
1047
1104
  }
1048
1105
  if (doubledLines) {
1049
1106
  this.fb32.copyWithin(offset + 1024, offset, offset + this.pixelsPerChar);
1050
1107
  }
1051
1108
  }
1052
1109
  if (this.cursorDrawIndex) {
1053
- this.handleCursor(offset, doubledLines);
1110
+ this.handleCursor(offset);
1054
1111
  }
1055
1112
  }
1056
1113
  }
@@ -1059,6 +1116,10 @@ export class Video {
1059
1116
  // the SAA5050 pipeline with the video bus data, forcing bit 6 high.
1060
1117
  // On real hardware IC37/IC36 operates regardless of ULA mode —
1061
1118
  // it is wired to the CRTC DISPEN signal, not the ULA teletext bit.
1119
+ // Hardware also clocks the chip here, which we do not: our row reset fires
1120
+ // too early relative to the pipeline for that to come out right, and adding
1121
+ // `advance()` alone moves three of the hardware reference pages. See
1122
+ // https://github.com/mattgodbolt/jsbeeb/issues/874
1062
1123
  if (!(this.dispEnabled & HDISPENABLE) && this.dispEnabled & VDISPENABLE) {
1063
1124
  this.teletext.fetchData(this.readVideoMem() | 0x40);
1064
1125
  }
@@ -1,9 +0,0 @@
1
- 0d=d:IFd VDUd:p=POINT(64*POS,1E3-VPOS*32):RETURN ELSEMODE2:GCOL0,-9:CLG
2
- 1d=9:REPEATVDU30:REPEATGOSUBFALSE:IFPOS=15VDU28,5,VPOS,14;11,26:IF0ELSEIFp=0PRINT:UNTIL0ELSEUNTILVPOS=25
3
- 2b=ABSRND MOD7:k=0:VDU31,9,3
4
- 3REPEATg=9-INKEY6MOD3
5
- 4FORl=TRUE TO1:o=l ANDSGNo
6
- 5IFo=l COLOURb-15:VDUl EORg:k=k+(g=7AND9-6*l)
7
- 6IF0ELSEFORf=0TO11:d=f/3OR2EORd:GOSUBFALSE
8
- 7IF2^((f+k)MOD12)AND975AND&C2590EC/8^b VDU2080*ABSl;:o=o+p:IF0ELSENEXT,
9
- 8VDU20:UNTILo*LOGg:UNTIL0