jsbeeb 1.19.0 → 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/README.md CHANGED
@@ -51,15 +51,15 @@ KEY.<host key>=<BBC key>
51
51
  Add one for each key you want to change. For example, Superior Software's Space Invaders fires with `COPY`; this makes
52
52
  `Enter` fire instead:
53
53
 
54
- [`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FSpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY)
54
+ [`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY`](https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY)
55
55
 
56
56
  Superior's Frogger uses `A`/`Z`/`DELETE`/`COPY` to move; this puts it on the arrow keys:
57
57
 
58
- [`https://bbc.xania.org/?disc1=sth:Superior/Frogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FFrogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY)
58
+ [`https://bbc.xania.org/?disc1=sth:Superior/Frogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY`](https://bbc.xania.org/?disc1=sth:Superior/Frogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY)
59
59
 
60
60
  And Superior's Hunchback steers with `CAPS LOCK` and `CTRL`, which the arrow keys can stand in for:
61
61
 
62
- [`https://bbc.xania.org/?disc1=sth:Superior/Hunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FHunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL)
62
+ [`https://bbc.xania.org/?disc1=sth:Superior/Hunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL`](https://bbc.xania.org/?disc1=sth:Superior/Hunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL)
63
63
 
64
64
  The **host key** names are jsbeeb's names for the keys on your own keyboard. Most are what you'd expect, but note:
65
65
 
@@ -141,7 +141,7 @@ GP.<gamepad control>=<BBC key>
141
141
  By default the D-pad presses the "Snapper" keys (`Z`, `X`, `:`, `/`), the `A` button presses `RETURN` and `Start`
142
142
  presses `SPACE`. To play Superior's Space Invaders on a pad, where `COPY` fires:
143
143
 
144
- [`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FSpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY)
144
+ [`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY`](https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY)
145
145
 
146
146
  The gamepad control names are:
147
147
 
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.0",
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/url-params.js CHANGED
@@ -41,18 +41,15 @@ export const ParamTypes = {
41
41
  export function parseQueryString(queryString, paramTypes = {}) {
42
42
  if (!queryString) return {};
43
43
 
44
- // workaround for shonky python web server
45
- const cleanQueryString = queryString.endsWith("/") ? queryString.substring(0, queryString.length - 1) : queryString;
46
-
47
44
  const parsedQuery = {};
48
45
 
49
- cleanQueryString.split("&").forEach(function (keyval) {
46
+ queryString.split("&").forEach(function (keyval) {
50
47
  if (!keyval) return;
51
48
 
52
49
  const keyAndVal = keyval.split("=");
53
50
  const key = decodeURIComponent(keyAndVal[0]);
54
51
  let val = null;
55
- if (keyAndVal.length > 1) val = decodeURIComponent(keyAndVal[1]);
52
+ if (keyAndVal.length > 1) val = decodeURIComponent(keyAndVal.slice(1).join("="));
56
53
 
57
54
  const paramType = paramTypes[key] || ParamTypes.STRING;
58
55
 
@@ -90,12 +87,26 @@ export function parseQueryString(queryString, paramTypes = {}) {
90
87
  }
91
88
 
92
89
  /**
93
- * Build a URL string from base URL and query parameters
94
- * @param {string} baseUrl - The base URL (without query string)
95
- * @param {Object} parsedQuery - Object containing query parameters
96
- * @param {Object.<string, ParamType>} [paramTypes={}] - Object mapping parameter names to their types
97
- * @returns {string} The complete URL with query parameters
90
+ * Characters RFC 3986 allows literally in a query that `encodeURIComponent` escapes anyway. `&`,
91
+ * `=`, `+`, `#`, `%` and space are left escaped because they delimit something, and `?` because a
92
+ * URL with a second one in it reads as broken even though the grammar permits it.
98
93
  */
94
+ const QueryLiterals = "$,/:;@";
95
+
96
+ const EscapedQueryLiterals = new RegExp(
97
+ [...QueryLiterals].map((char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`).join("|"),
98
+ "g",
99
+ );
100
+
101
+ /**
102
+ * Percent-encode a key or value for a query string, escaping only what delimits something
103
+ * @param {string} component - The key or value to encode
104
+ * @returns {string} The encoded component
105
+ */
106
+ function encodeQueryComponent(component) {
107
+ return encodeURIComponent(component).replace(EscapedQueryLiterals, decodeURIComponent);
108
+ }
109
+
99
110
  /**
100
111
  * Append a parameter to the URL
101
112
  * @param {string} url - Current URL
@@ -105,13 +116,20 @@ export function parseQueryString(queryString, paramTypes = {}) {
105
116
  * @returns {Object} Updated URL and separator
106
117
  */
107
118
  function appendParam(url, sep, key, value = undefined) {
108
- url += sep + encodeURIComponent(key);
119
+ url += sep + encodeQueryComponent(key);
109
120
  if (value !== undefined) {
110
- url += "=" + encodeURIComponent(value);
121
+ url += "=" + encodeQueryComponent(value);
111
122
  }
112
123
  return { url, sep: "&" };
113
124
  }
114
125
 
126
+ /**
127
+ * Build a URL string from base URL and query parameters
128
+ * @param {string} baseUrl - The base URL (without query string)
129
+ * @param {Object} parsedQuery - Object containing query parameters
130
+ * @param {Object.<string, ParamType>} [paramTypes={}] - Object mapping parameter names to their types
131
+ * @returns {string} The complete URL with query parameters
132
+ */
115
133
  export function buildUrlFromParams(baseUrl, parsedQuery, paramTypes = {}) {
116
134
  let url = baseUrl;
117
135
  let sep = "?";
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