jsbeeb 1.4.0 → 1.5.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/teletext.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  import { makeChars } from "./teletext_data.js";
3
3
  import { makeFast32 } from "./utils.js";
4
+ import { BbcDefaultPalette as BbcDefaultCollook } from "./bbc-palette.js";
4
5
 
5
6
  export class Teletext {
6
7
  constructor() {
@@ -35,22 +36,8 @@ export class Teletext {
35
36
  init() {
36
37
  const charData = makeChars();
37
38
 
38
- // Build palette
39
- const gamma = 1.0 / 2.2;
40
- for (let i = 0; i < 256; ++i) {
41
- const alpha = (i & 3) / 3.0;
42
- const foregroundR = !!(i & 4);
43
- const foregroundG = !!(i & 8);
44
- const foregroundB = !!(i & 16);
45
- const backgroundR = !!(i & 32);
46
- const backgroundG = !!(i & 64);
47
- const backgroundB = !!(i & 128);
48
- // Gamma-corrected blending
49
- const blendedR = Math.pow(foregroundR * alpha + backgroundR * (1.0 - alpha), gamma) * 240;
50
- const blendedG = Math.pow(foregroundG * alpha + backgroundG * (1.0 - alpha), gamma) * 240;
51
- const blendedB = Math.pow(foregroundB * alpha + backgroundB * (1.0 - alpha), gamma) * 240;
52
- this.colour[i] = blendedR | (blendedG << 8) | (blendedB << 16) | (0xff << 24);
53
- }
39
+ // Build palette from BBC default colours.
40
+ this.rebuildColours(BbcDefaultCollook);
54
41
 
55
42
  function getLoResGlyphRow(c, row) {
56
43
  if (row < 0 || row >= 20) {
@@ -135,6 +122,34 @@ export class Teletext {
135
122
  makeHiResGlyphs(this.separatedGlyphs, true);
136
123
  }
137
124
 
125
+ /**
126
+ * Rebuild the 256-entry colour lookup from a 16-entry ABGR collook palette.
127
+ * Each entry encodes: bits 7-5 = background colour index (0-7),
128
+ * bits 4-2 = foreground colour index (0-7), bits 1-0 = blend weight.
129
+ * Reference: b-em mode7_gen_nula_lookup().
130
+ * @param {Uint32Array} collook - 16-entry palette in ABGR format (0xffBBGGRR).
131
+ */
132
+ rebuildColours(collook) {
133
+ const gamma = 1.0 / 2.2;
134
+ for (let i = 0; i < 256; ++i) {
135
+ const weight = (i & 3) / 3.0;
136
+ const fgIndex = (i >> 2) & 7;
137
+ const bgIndex = (i >> 5) & 7;
138
+ const fgColour = collook[fgIndex];
139
+ const bgColour = collook[bgIndex];
140
+ const fgR = (fgColour & 0xff) / 255;
141
+ const fgG = ((fgColour >> 8) & 0xff) / 255;
142
+ const fgB = ((fgColour >> 16) & 0xff) / 255;
143
+ const bgR = (bgColour & 0xff) / 255;
144
+ const bgG = ((bgColour >> 8) & 0xff) / 255;
145
+ const bgB = ((bgColour >> 16) & 0xff) / 255;
146
+ const blendedR = Math.pow(fgR * weight + bgR * (1.0 - weight), gamma) * 240;
147
+ const blendedG = Math.pow(fgG * weight + bgG * (1.0 - weight), gamma) * 240;
148
+ const blendedB = Math.pow(fgB * weight + bgB * (1.0 - weight), gamma) * 240;
149
+ this.colour[i] = blendedR | 0 | ((blendedG | 0) << 8) | ((blendedB | 0) << 16) | (0xff << 24);
150
+ }
151
+ }
152
+
138
153
  setNextChars() {
139
154
  if (this.gfx) {
140
155
  if (this.sep) {
package/src/via.js CHANGED
@@ -460,7 +460,7 @@ class Via {
460
460
  }
461
461
 
462
462
  export class SysVia extends Via {
463
- constructor(cpu, scheduler, video, soundChip, cmos, isMaster, initialLayout, getGamepads) {
463
+ constructor(cpu, scheduler, { video, soundChip, cmos, isMaster, initialLayout, getGamepads } = {}) {
464
464
  super(cpu, scheduler, 0x01);
465
465
 
466
466
  this.IC32 = 0;
package/src/video.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  import { Teletext } from "./teletext.js";
3
3
  import * as utils from "./utils.js";
4
+ import { BbcDefaultPalette as NulaDefaultPalette } from "./bbc-palette.js";
4
5
 
5
6
  export const VDISPENABLE = 1 << 0;
6
7
  export const HDISPENABLE = 1 << 1;
@@ -15,43 +16,182 @@ export const OPAQUE_BLACK = 0xff000000;
15
16
  export const OPAQUE_WHITE = 0xffffffff;
16
17
 
17
18
  ////////////////////
18
- // ULA interface
19
+ // VideoNULA - programmable 12-bit RGB palette extension (RobC hardware mod).
20
+ // Reference: b-em src/video.c (stardot/b-em).
21
+ // Addresses &FE22 (control) and &FE23 (palette) via 2-byte write protocol.
22
+
23
+ ////////////////////
24
+ // ULA interface (includes NULA programmable palette support)
19
25
  class Ula {
20
26
  constructor(video) {
21
27
  this.video = video;
28
+ // NULA state
29
+ this.collook = new Uint32Array(16);
30
+ this.flash = new Uint8Array(8);
31
+ this.paletteWriteFlag = false;
32
+ this.paletteFirstByte = 0;
33
+ this.paletteMode = 0;
34
+ this.horizontalOffset = 0;
35
+ this.leftBlank = 0;
36
+ this.disabled = false;
37
+ this.attributeMode = 0;
38
+ this.attributeText = 0;
39
+ this.reset();
40
+ }
41
+
42
+ reset() {
43
+ this.collook.set(NulaDefaultPalette);
44
+ this.flash.fill(1);
45
+ this.paletteWriteFlag = false;
46
+ this.paletteFirstByte = 0;
47
+ this.paletteMode = 0;
48
+ this.horizontalOffset = 0;
49
+ this.leftBlank = 0;
50
+ this.attributeMode = 0;
51
+ this.attributeText = 0;
52
+ // Note: disabled is NOT cleared by reset (matches b-em behaviour).
53
+ // Recompute rendered palette so any custom NULA colours are flushed.
54
+ this._recomputeUlaPal(!!(this.video.ulactrl & 1));
55
+ // Rebuild MODE 7 teletext colours from the restored default palette.
56
+ this.video.teletext.rebuildColours(this.collook);
22
57
  }
23
58
 
24
59
  write(addr, val) {
25
60
  addr |= 0;
26
61
  val |= 0;
27
- if (addr & 1) {
28
- const index = (val >>> 4) & 0xf;
29
- this.video.actualPal[index] = val & 0xf;
30
- let ulaCol = val & 7;
31
- if (!(val & 8 && this.video.ulactrl & 1)) ulaCol ^= 7;
32
- if (this.video.ulaPal[index] !== this.video.collook[ulaCol]) {
33
- this.video.ulaPal[index] = this.video.collook[ulaCol];
34
- }
62
+ let reg = addr & 3;
63
+
64
+ // When NULA is disabled, mask off bit 1 so &FE22/&FE23 become &FE20/&FE21.
65
+ if (reg >= 2 && this.disabled) {
66
+ reg &= ~2;
67
+ }
68
+
69
+ switch (reg) {
70
+ case 0:
71
+ this._writeControl(val);
72
+ break;
73
+ case 1:
74
+ this._writePalette(val);
75
+ break;
76
+ case 2:
77
+ this._writeNulaControl(val);
78
+ break;
79
+ case 3:
80
+ this._writeNulaPalette(val);
81
+ break;
82
+ }
83
+ }
84
+
85
+ // ULA control register (&FE20).
86
+ _writeControl(val) {
87
+ if ((this.video.ulactrl ^ val) & 1) {
88
+ // Flash state has changed - recompute all palette entries.
89
+ this._recomputeUlaPal(!!(val & 1));
90
+ }
91
+ this.video.ulactrl = val;
92
+ this.video.pixelsPerChar = val & 0x10 ? 8 : 16;
93
+ this.video.halfClock = !(val & 0x10);
94
+ const newMode = (val >>> 2) & 3;
95
+ if (newMode !== this.video.ulaMode) {
96
+ this.video.ulaMode = newMode;
97
+ }
98
+ this.video.teletextMode = !!(val & 2);
99
+ }
100
+
101
+ // ULA palette register (&FE21).
102
+ _writePalette(val) {
103
+ const index = (val >>> 4) & 0xf;
104
+ this.video.actualPal[index] = val & 0xf;
105
+ // Default: XOR lower 3 bits with 7 for steady colour.
106
+ let colour = this.collook[(val & 0xf) ^ 7];
107
+ // Flash override: if flash bit set, flash globally enabled, and per-colour flash active.
108
+ if (val & 8 && this.video.ulactrl & 1 && this.flash[(val & 7) ^ 7]) {
109
+ colour = this.collook[val & 0xf];
110
+ }
111
+ if (this.video.ulaPal[index] !== colour) {
112
+ this.video.ulaPal[index] = colour;
113
+ }
114
+ }
115
+
116
+ // NULA control register (&FE22).
117
+ _writeNulaControl(val) {
118
+ const reg = (val >>> 4) & 0xf;
119
+ const param = val & 0xf;
120
+ switch (reg) {
121
+ case 1:
122
+ this.paletteMode = param & 1;
123
+ break;
124
+ case 2:
125
+ this.horizontalOffset = param & 7;
126
+ break;
127
+ case 3:
128
+ this.leftBlank = param & 0xf;
129
+ break;
130
+ case 4:
131
+ this.reset();
132
+ break;
133
+ case 5:
134
+ this.disabled = true;
135
+ break;
136
+ case 6:
137
+ this.attributeMode = param & 3;
138
+ break;
139
+ case 7:
140
+ this.attributeText = param & 1;
141
+ break;
142
+ case 8:
143
+ this.flash[0] = param & 8 ? 1 : 0;
144
+ this.flash[1] = param & 4 ? 1 : 0;
145
+ this.flash[2] = param & 2 ? 1 : 0;
146
+ this.flash[3] = param & 1 ? 1 : 0;
147
+ this._recomputeUlaPal(!!(this.video.ulactrl & 1));
148
+ break;
149
+ case 9:
150
+ this.flash[4] = param & 8 ? 1 : 0;
151
+ this.flash[5] = param & 4 ? 1 : 0;
152
+ this.flash[6] = param & 2 ? 1 : 0;
153
+ this.flash[7] = param & 1 ? 1 : 0;
154
+ this._recomputeUlaPal(!!(this.video.ulactrl & 1));
155
+ break;
156
+ // Regs 14 (border colour) and 15 (blank colour) are stubbed - rendering not yet implemented.
157
+ }
158
+ }
159
+
160
+ // NULA palette register (&FE23) - 2-byte write protocol.
161
+ _writeNulaPalette(val) {
162
+ if (this.paletteWriteFlag) {
163
+ const c = (this.paletteFirstByte >>> 4) & 0xf;
164
+ const r = this.paletteFirstByte & 0x0f;
165
+ const g = (val >>> 4) & 0x0f;
166
+ const b = val & 0x0f;
167
+ // Expand 4-bit channels to 8-bit by duplicating the nibble.
168
+ // Store in ABGR format (Uint32Array on little-endian = canvas RGBA).
169
+ this.collook[c] = 0xff000000 | ((b | (b << 4)) << 16) | ((g | (g << 4)) << 8) | (r | (r << 4));
170
+ // Colours 8-15 default to solid (non-flashing) when programmed.
171
+ if (c >= 8) this.flash[c - 8] = 0;
172
+ // Recompute all rendered palette entries from current state.
173
+ this._recomputeUlaPal(!!(this.video.ulactrl & 1));
174
+ // MODE 7 teletext uses its own colour lookup; rebuild when a base colour changes.
175
+ if (c < 8) this.video.teletext.rebuildColours(this.collook);
35
176
  } else {
36
- if ((this.video.ulactrl ^ val) & 1) {
37
- // Flash colour has changed.
38
- const flashEnabled = !!(val & 1);
39
- for (let i = 0; i < 16; ++i) {
40
- let index = this.video.actualPal[i] & 7;
41
- if (!(flashEnabled && this.video.actualPal[i] & 8)) index ^= 7;
42
- if (this.video.ulaPal[i] !== this.video.collook[index]) {
43
- this.video.ulaPal[i] = this.video.collook[index];
44
- }
45
- }
177
+ this.paletteFirstByte = val;
178
+ }
179
+ this.paletteWriteFlag = !this.paletteWriteFlag;
180
+ }
181
+
182
+ // Recompute all 16 ulaPal entries from actualPal + NULA collook + flash state.
183
+ // Follows b-em's palette recomputation logic exactly.
184
+ _recomputeUlaPal(flashEnabled) {
185
+ const video = this.video;
186
+ for (let i = 0; i < 16; ++i) {
187
+ const palVal = video.actualPal[i];
188
+ let colour = this.collook[(palVal & 0xf) ^ 7];
189
+ if (palVal & 8 && flashEnabled && this.flash[(palVal & 7) ^ 7]) {
190
+ colour = this.collook[palVal & 0xf];
46
191
  }
47
- this.video.ulactrl = val;
48
- this.video.pixelsPerChar = val & 0x10 ? 8 : 16;
49
- this.video.halfClock = !(val & 0x10);
50
- const newMode = (val >>> 2) & 3;
51
- if (newMode !== this.video.ulaMode) {
52
- this.video.ulaMode = newMode;
192
+ if (video.ulaPal[i] !== colour) {
193
+ video.ulaPal[i] = colour;
53
194
  }
54
- this.video.teletextMode = !!(val & 2);
55
195
  }
56
196
  }
57
197
  }
@@ -320,16 +460,20 @@ export class Video {
320
460
  destOffset |= 0;
321
461
  const offset = table4bppOffset(this.ulaMode, dat);
322
462
  const fb32 = this.fb32;
323
- const ulaPal = this.ulaPal;
463
+ // In NULA palette mode, bypass the ULA palette (ulaPal) and look up
464
+ // pixel colours directly from the NULA 12-bit colour table (collook).
465
+ // This skips the XOR-7 logical↔physical colour mapping that the
466
+ // standard ULA applies. Reference: b-em src/video.c lines 1083, 1117.
467
+ const colourLookup = this.ula.paletteMode ? this.ula.collook : this.ulaPal;
324
468
  const table4bpp = this.table4bpp;
325
469
  // Take advantage of numPixels being either 8 or 16
326
470
  if (numPixels === 8) {
327
471
  for (let i = 0; i < 8; ++i) {
328
- fb32[destOffset + i] = ulaPal[table4bpp[offset + i]];
472
+ fb32[destOffset + i] = colourLookup[table4bpp[offset + i]];
329
473
  }
330
474
  } else {
331
475
  for (let i = 0; i < 16; ++i) {
332
- fb32[destOffset + i] = ulaPal[table4bpp[offset + i]];
476
+ fb32[destOffset + i] = colourLookup[table4bpp[offset + i]];
333
477
  }
334
478
  }
335
479
  }
@@ -656,9 +800,11 @@ export class Video {
656
800
  // Read data from address pointer if both horizontal and vertical display enabled.
657
801
  const dat = this.readVideoMem();
658
802
  if (insideBorder) {
659
- if (this.teletextMode) {
660
- this.teletext.fetchData(dat);
661
- }
803
+ // Always feed the SAA5050 pipeline: on real hardware IC15
804
+ // permanently connects the video bus to the SAA5050 inputs
805
+ // regardless of ULA mode. Required for the "TTX trick".
806
+ // See https://github.com/mattgodbolt/jsbeeb/issues/546
807
+ this.teletext.fetchData(dat);
662
808
 
663
809
  // Check cursor start.
664
810
  if (
@@ -691,7 +837,17 @@ export class Video {
691
837
 
692
838
  if ((this.dispEnabled & EVERYTHINGENABLED) === EVERYTHINGENABLED) {
693
839
  if (this.teletextMode) {
694
- this.teletext.render(this.fb32, offset);
840
+ if (this.halfClock) {
841
+ // Proper MODE 7 (1MHz clock + teletext): render SAA5050 output normally.
842
+ this.teletext.render(this.fb32, offset);
843
+ } else {
844
+ // 2MHz clock + teletext bit set (the "TTX trick"): the Video ULA
845
+ // forces DISPEN to the SAA5050 to 0 in 2MHz modes (0/1/2/3), so
846
+ // the SAA5050 outputs black. Confirmed by Rich Talbot-Watkins (RTW)
847
+ // at ABUG 2026-03-13.
848
+ // See https://github.com/mattgodbolt/jsbeeb/issues/546
849
+ this.fb32.fill(OPAQUE_BLACK, offset, offset + this.pixelsPerChar);
850
+ }
695
851
  } else {
696
852
  this.blitFb(dat, offset, this.pixelsPerChar, doubledLines);
697
853
  }
@@ -705,6 +861,14 @@ export class Video {
705
861
  }
706
862
  }
707
863
 
864
+ // IC37/IC36: during H blanking with V display active, always feed
865
+ // the SAA5050 pipeline with the video bus data, forcing bit 6 high.
866
+ // On real hardware IC37/IC36 operates regardless of ULA mode —
867
+ // it is wired to the CRTC DISPEN signal, not the ULA teletext bit.
868
+ if (!(this.dispEnabled & HDISPENABLE) && this.dispEnabled & VDISPENABLE) {
869
+ this.teletext.fetchData(this.readVideoMem() | 0x40);
870
+ }
871
+
708
872
  // CRTC MA always increments, inside display border or not.
709
873
  this.addr = (this.addr + 1) & 0x3fff;
710
874
 
@@ -777,11 +941,19 @@ export class Video {
777
941
 
778
942
  export class FakeVideo {
779
943
  constructor() {
780
- this.ula = this.crtc = {
944
+ this.crtc = {
945
+ read: function () {
946
+ return 0xff;
947
+ },
948
+ write: utils.noop,
949
+ };
950
+ this.ula = {
781
951
  read: function () {
782
952
  return 0xff;
783
953
  },
784
954
  write: utils.noop,
955
+ reset: utils.noop,
956
+ disabled: false,
785
957
  };
786
958
  this.regs = new Uint8Array(32);
787
959
  }
package/src/wd-fdc.js CHANGED
@@ -804,7 +804,12 @@ export class WdFdc {
804
804
  if (this._indexPulseCount < 6) return;
805
805
  if (this._commandType === 1) this._statusRegister |= Status.typeISpinUpDone;
806
806
  if (this._isCommandSettle) {
807
- const settleMs = this._is1772 ? 15 : 30;
807
+ // The WD1770 datasheet specifies 30ms head settle, but empirical testing
808
+ // against b-em (which uses 15ms for all WD1770/2 variants) shows 15ms is
809
+ // correct for BBC hardware. Using 30ms causes disc streaming demos (e.g.
810
+ // STNICC-beeb on Master 128) to hang because sectors don't arrive before
811
+ // the first vsync IRQ. The WD1772 also uses 15ms per its datasheet.
812
+ const settleMs = 15;
808
813
  this._startTimer(TimerState.settle, settleMs * 1000);
809
814
  } else {
810
815
  this._dispatchCommand();
@@ -9,7 +9,7 @@ const rendererUrl = new URL("./audio-renderer.js", import.meta.url).href;
9
9
  const music5000WorkletUrl = new URL("../music5000-worklet.js", import.meta.url).href;
10
10
 
11
11
  export class AudioHandler {
12
- constructor(warningNode, statsNode, audioFilterFreq, audioFilterQ, noSeek) {
12
+ constructor({ warningNode, statsNode, audioFilterFreq, audioFilterQ, noSeek } = {}) {
13
13
  this.warningNode = warningNode;
14
14
  this.warningNode.toggle(false);
15
15
  this.chart = new SmoothieChart({