jsbeeb 1.4.0 → 1.6.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/via.js CHANGED
@@ -402,6 +402,73 @@ class Via {
402
402
  this.portBUpdated();
403
403
  }
404
404
 
405
+ snapshotState() {
406
+ return {
407
+ ora: this.ora,
408
+ orb: this.orb,
409
+ ira: this.ira,
410
+ irb: this.irb,
411
+ ddra: this.ddra,
412
+ ddrb: this.ddrb,
413
+ sr: this.sr,
414
+ t1l: this.t1l,
415
+ t2l: this.t2l,
416
+ t1c: this.t1c,
417
+ t2c: this.t2c,
418
+ acr: this.acr,
419
+ pcr: this.pcr,
420
+ ifr: this.ifr,
421
+ ier: this.ier,
422
+ t1hit: this.t1hit,
423
+ t2hit: this.t2hit,
424
+ portapins: this.portapins,
425
+ portbpins: this.portbpins,
426
+ ca1: this.ca1,
427
+ ca2: this.ca2,
428
+ cb1: this.cb1,
429
+ cb2: this.cb2,
430
+ justhit: this.justhit,
431
+ t1_pb7: this.t1_pb7,
432
+ lastPolltime: this.lastPolltime,
433
+ taskOffset: this.task.scheduled() ? this.task.expireEpoch - this.scheduler.epoch : null,
434
+ };
435
+ }
436
+
437
+ restoreState(state) {
438
+ this.ora = state.ora;
439
+ this.orb = state.orb;
440
+ this.ira = state.ira;
441
+ this.irb = state.irb;
442
+ this.ddra = state.ddra;
443
+ this.ddrb = state.ddrb;
444
+ this.sr = state.sr;
445
+ this.t1l = state.t1l;
446
+ this.t2l = state.t2l;
447
+ this.t1c = state.t1c;
448
+ this.t2c = state.t2c;
449
+ this.acr = state.acr;
450
+ this.pcr = state.pcr;
451
+ this.ifr = state.ifr;
452
+ this.ier = state.ier;
453
+ this.t1hit = state.t1hit;
454
+ this.t2hit = state.t2hit;
455
+ this.portapins = state.portapins;
456
+ this.portbpins = state.portbpins;
457
+ this.ca1 = state.ca1;
458
+ this.ca2 = state.ca2;
459
+ this.cb1 = state.cb1;
460
+ this.cb2 = state.cb2;
461
+ this.justhit = state.justhit;
462
+ this.t1_pb7 = state.t1_pb7;
463
+ this.lastPolltime = state.lastPolltime;
464
+ this.updateIFR();
465
+ if (state.taskOffset !== null) {
466
+ this.task.reschedule(state.taskOffset);
467
+ } else {
468
+ this.task.cancel();
469
+ }
470
+ }
471
+
405
472
  setca1(level) {
406
473
  if (level === this.ca1) return;
407
474
  const pcrSet = !!(this.pcr & 1);
@@ -460,7 +527,7 @@ class Via {
460
527
  }
461
528
 
462
529
  export class SysVia extends Via {
463
- constructor(cpu, scheduler, video, soundChip, cmos, isMaster, initialLayout, getGamepads) {
530
+ constructor(cpu, scheduler, { video, soundChip, cmos, isMaster, initialLayout, getGamepads } = {}) {
464
531
  super(cpu, scheduler, 0x01);
465
532
 
466
533
  this.IC32 = 0;
@@ -484,6 +551,30 @@ export class SysVia extends Via {
484
551
  this.reset();
485
552
  }
486
553
 
554
+ snapshotState() {
555
+ return {
556
+ ...super.snapshotState(),
557
+ IC32: this.IC32,
558
+ capsLockLight: this.capsLockLight,
559
+ shiftLockLight: this.shiftLockLight,
560
+ };
561
+ }
562
+
563
+ restoreState(state) {
564
+ super.restoreState(state);
565
+ this.IC32 = state.IC32;
566
+ this.capsLockLight = state.capsLockLight;
567
+ this.shiftLockLight = state.shiftLockLight;
568
+ // Re-apply IC32 side effects (sound chip data bus, CMOS, port A)
569
+ // Must call portBUpdated directly (not recalculatePortBPins which
570
+ // overwrites IC32 based on portbpins) and then re-set IC32.
571
+ this.portBUpdated();
572
+ this.IC32 = state.IC32;
573
+ this.capsLockLight = state.capsLockLight;
574
+ this.shiftLockLight = state.shiftLockLight;
575
+ this.recalculatePortAPins();
576
+ }
577
+
487
578
  setKeyLayout(map) {
488
579
  this.keycodeToRowCol = utils.getKeyMap(map);
489
580
  }
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,212 @@ 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
+ snapshotState() {
86
+ return {
87
+ collook: this.collook.slice(),
88
+ flash: this.flash.slice(),
89
+ paletteWriteFlag: this.paletteWriteFlag,
90
+ paletteFirstByte: this.paletteFirstByte,
91
+ paletteMode: this.paletteMode,
92
+ horizontalOffset: this.horizontalOffset,
93
+ leftBlank: this.leftBlank,
94
+ disabled: this.disabled,
95
+ attributeMode: this.attributeMode,
96
+ attributeText: this.attributeText,
97
+ };
98
+ }
99
+
100
+ restoreState(state) {
101
+ this.collook.set(state.collook);
102
+ this.flash.set(state.flash);
103
+ this.paletteWriteFlag = state.paletteWriteFlag;
104
+ this.paletteFirstByte = state.paletteFirstByte;
105
+ this.paletteMode = state.paletteMode;
106
+ this.horizontalOffset = state.horizontalOffset;
107
+ this.leftBlank = state.leftBlank;
108
+ this.disabled = state.disabled;
109
+ this.attributeMode = state.attributeMode;
110
+ this.attributeText = state.attributeText;
111
+ this._recomputeUlaPal(!!(this.video.ulactrl & 1));
112
+ this.video.teletext.rebuildColours(this.collook);
113
+ }
114
+
115
+ // ULA control register (&FE20).
116
+ _writeControl(val) {
117
+ if ((this.video.ulactrl ^ val) & 1) {
118
+ // Flash state has changed - recompute all palette entries.
119
+ this._recomputeUlaPal(!!(val & 1));
120
+ }
121
+ this.video.ulactrl = val;
122
+ this.video.pixelsPerChar = val & 0x10 ? 8 : 16;
123
+ this.video.halfClock = !(val & 0x10);
124
+ const newMode = (val >>> 2) & 3;
125
+ if (newMode !== this.video.ulaMode) {
126
+ this.video.ulaMode = newMode;
127
+ }
128
+ this.video.teletextMode = !!(val & 2);
129
+ }
130
+
131
+ // ULA palette register (&FE21).
132
+ _writePalette(val) {
133
+ const index = (val >>> 4) & 0xf;
134
+ this.video.actualPal[index] = val & 0xf;
135
+ // Default: XOR lower 3 bits with 7 for steady colour.
136
+ let colour = this.collook[(val & 0xf) ^ 7];
137
+ // Flash override: if flash bit set, flash globally enabled, and per-colour flash active.
138
+ if (val & 8 && this.video.ulactrl & 1 && this.flash[(val & 7) ^ 7]) {
139
+ colour = this.collook[val & 0xf];
140
+ }
141
+ if (this.video.ulaPal[index] !== colour) {
142
+ this.video.ulaPal[index] = colour;
143
+ }
144
+ }
145
+
146
+ // NULA control register (&FE22).
147
+ _writeNulaControl(val) {
148
+ const reg = (val >>> 4) & 0xf;
149
+ const param = val & 0xf;
150
+ switch (reg) {
151
+ case 1:
152
+ this.paletteMode = param & 1;
153
+ break;
154
+ case 2:
155
+ this.horizontalOffset = param & 7;
156
+ break;
157
+ case 3:
158
+ this.leftBlank = param & 0xf;
159
+ break;
160
+ case 4:
161
+ this.reset();
162
+ break;
163
+ case 5:
164
+ this.disabled = true;
165
+ break;
166
+ case 6:
167
+ this.attributeMode = param & 3;
168
+ break;
169
+ case 7:
170
+ this.attributeText = param & 1;
171
+ break;
172
+ case 8:
173
+ this.flash[0] = param & 8 ? 1 : 0;
174
+ this.flash[1] = param & 4 ? 1 : 0;
175
+ this.flash[2] = param & 2 ? 1 : 0;
176
+ this.flash[3] = param & 1 ? 1 : 0;
177
+ this._recomputeUlaPal(!!(this.video.ulactrl & 1));
178
+ break;
179
+ case 9:
180
+ this.flash[4] = param & 8 ? 1 : 0;
181
+ this.flash[5] = param & 4 ? 1 : 0;
182
+ this.flash[6] = param & 2 ? 1 : 0;
183
+ this.flash[7] = param & 1 ? 1 : 0;
184
+ this._recomputeUlaPal(!!(this.video.ulactrl & 1));
185
+ break;
186
+ // Regs 14 (border colour) and 15 (blank colour) are stubbed - rendering not yet implemented.
187
+ }
188
+ }
189
+
190
+ // NULA palette register (&FE23) - 2-byte write protocol.
191
+ _writeNulaPalette(val) {
192
+ if (this.paletteWriteFlag) {
193
+ const c = (this.paletteFirstByte >>> 4) & 0xf;
194
+ const r = this.paletteFirstByte & 0x0f;
195
+ const g = (val >>> 4) & 0x0f;
196
+ const b = val & 0x0f;
197
+ // Expand 4-bit channels to 8-bit by duplicating the nibble.
198
+ // Store in ABGR format (Uint32Array on little-endian = canvas RGBA).
199
+ this.collook[c] = 0xff000000 | ((b | (b << 4)) << 16) | ((g | (g << 4)) << 8) | (r | (r << 4));
200
+ // Colours 8-15 default to solid (non-flashing) when programmed.
201
+ if (c >= 8) this.flash[c - 8] = 0;
202
+ // Recompute all rendered palette entries from current state.
203
+ this._recomputeUlaPal(!!(this.video.ulactrl & 1));
204
+ // MODE 7 teletext uses its own colour lookup; rebuild when a base colour changes.
205
+ if (c < 8) this.video.teletext.rebuildColours(this.collook);
35
206
  } 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
- }
207
+ this.paletteFirstByte = val;
208
+ }
209
+ this.paletteWriteFlag = !this.paletteWriteFlag;
210
+ }
211
+
212
+ // Recompute all 16 ulaPal entries from actualPal + NULA collook + flash state.
213
+ // Follows b-em's palette recomputation logic exactly.
214
+ _recomputeUlaPal(flashEnabled) {
215
+ const video = this.video;
216
+ for (let i = 0; i < 16; ++i) {
217
+ const palVal = video.actualPal[i];
218
+ let colour = this.collook[(palVal & 0xf) ^ 7];
219
+ if (palVal & 8 && flashEnabled && this.flash[(palVal & 7) ^ 7]) {
220
+ colour = this.collook[palVal & 0xf];
46
221
  }
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;
222
+ if (video.ulaPal[i] !== colour) {
223
+ video.ulaPal[i] = colour;
53
224
  }
54
- this.video.teletextMode = !!(val & 2);
55
225
  }
56
226
  }
57
227
  }
@@ -68,6 +238,14 @@ class Crtc {
68
238
  ]);
69
239
  }
70
240
 
241
+ snapshotState() {
242
+ return { curReg: this.curReg };
243
+ }
244
+
245
+ restoreState(state) {
246
+ this.curReg = state.curReg;
247
+ }
248
+
71
249
  read(addr) {
72
250
  if (!(addr & 1)) return 0;
73
251
  switch (this.curReg) {
@@ -249,6 +427,111 @@ export class Video {
249
427
  this.paint();
250
428
  }
251
429
 
430
+ snapshotState() {
431
+ return {
432
+ regs: this.regs.slice(),
433
+ bitmapX: this.bitmapX,
434
+ bitmapY: this.bitmapY,
435
+ oddClock: this.oddClock,
436
+ frameCount: this.frameCount,
437
+ doEvenFrameLogic: this.doEvenFrameLogic,
438
+ isEvenRender: this.isEvenRender,
439
+ lastRenderWasEven: this.lastRenderWasEven,
440
+ firstScanline: this.firstScanline,
441
+ inHSync: this.inHSync,
442
+ inVSync: this.inVSync,
443
+ hadVSyncThisRow: this.hadVSyncThisRow,
444
+ checkVertAdjust: this.checkVertAdjust,
445
+ endOfMainLatched: this.endOfMainLatched,
446
+ endOfVertAdjustLatched: this.endOfVertAdjustLatched,
447
+ endOfFrameLatched: this.endOfFrameLatched,
448
+ inVertAdjust: this.inVertAdjust,
449
+ inDummyRaster: this.inDummyRaster,
450
+ hpulseWidth: this.hpulseWidth,
451
+ vpulseWidth: this.vpulseWidth,
452
+ hpulseCounter: this.hpulseCounter,
453
+ vpulseCounter: this.vpulseCounter,
454
+ dispEnabled: this.dispEnabled,
455
+ horizCounter: this.horizCounter,
456
+ vertCounter: this.vertCounter,
457
+ scanlineCounter: this.scanlineCounter,
458
+ vertAdjustCounter: this.vertAdjustCounter,
459
+ addr: this.addr,
460
+ lineStartAddr: this.lineStartAddr,
461
+ nextLineStartAddr: this.nextLineStartAddr,
462
+ ulactrl: this.ulactrl,
463
+ pixelsPerChar: this.pixelsPerChar,
464
+ halfClock: this.halfClock,
465
+ ulaMode: this.ulaMode,
466
+ teletextMode: this.teletextMode,
467
+ displayEnableSkew: this.displayEnableSkew,
468
+ ulaPal: this.ulaPal.slice(),
469
+ actualPal: this.actualPal.slice(),
470
+ cursorOn: this.cursorOn,
471
+ cursorOff: this.cursorOff,
472
+ cursorOnThisFrame: this.cursorOnThisFrame,
473
+ cursorDrawIndex: this.cursorDrawIndex,
474
+ cursorPos: this.cursorPos,
475
+ interlacedSyncAndVideo: this.interlacedSyncAndVideo,
476
+ screenSubtract: this.screenSubtract,
477
+ ula: this.ula.snapshotState(),
478
+ crtc: this.crtc.snapshotState(),
479
+ teletext: this.teletext.snapshotState(),
480
+ };
481
+ }
482
+
483
+ restoreState(state) {
484
+ this.regs.set(state.regs);
485
+ this.bitmapX = state.bitmapX;
486
+ this.bitmapY = state.bitmapY;
487
+ this.oddClock = state.oddClock;
488
+ this.frameCount = state.frameCount;
489
+ this.doEvenFrameLogic = state.doEvenFrameLogic;
490
+ this.isEvenRender = state.isEvenRender;
491
+ this.lastRenderWasEven = state.lastRenderWasEven;
492
+ this.firstScanline = state.firstScanline;
493
+ this.inHSync = state.inHSync;
494
+ this.inVSync = state.inVSync;
495
+ this.hadVSyncThisRow = state.hadVSyncThisRow;
496
+ this.checkVertAdjust = state.checkVertAdjust;
497
+ this.endOfMainLatched = state.endOfMainLatched;
498
+ this.endOfVertAdjustLatched = state.endOfVertAdjustLatched;
499
+ this.endOfFrameLatched = state.endOfFrameLatched;
500
+ this.inVertAdjust = state.inVertAdjust;
501
+ this.inDummyRaster = state.inDummyRaster;
502
+ this.hpulseWidth = state.hpulseWidth;
503
+ this.vpulseWidth = state.vpulseWidth;
504
+ this.hpulseCounter = state.hpulseCounter;
505
+ this.vpulseCounter = state.vpulseCounter;
506
+ this.dispEnabled = state.dispEnabled;
507
+ this.horizCounter = state.horizCounter;
508
+ this.vertCounter = state.vertCounter;
509
+ this.scanlineCounter = state.scanlineCounter;
510
+ this.vertAdjustCounter = state.vertAdjustCounter;
511
+ this.addr = state.addr;
512
+ this.lineStartAddr = state.lineStartAddr;
513
+ this.nextLineStartAddr = state.nextLineStartAddr;
514
+ this.ulactrl = state.ulactrl;
515
+ this.pixelsPerChar = state.pixelsPerChar;
516
+ this.halfClock = state.halfClock;
517
+ this.ulaMode = state.ulaMode;
518
+ this.teletextMode = state.teletextMode;
519
+ this.displayEnableSkew = state.displayEnableSkew;
520
+ this.actualPal.set(state.actualPal);
521
+ this.cursorOn = state.cursorOn;
522
+ this.cursorOff = state.cursorOff;
523
+ this.cursorOnThisFrame = state.cursorOnThisFrame;
524
+ this.cursorDrawIndex = state.cursorDrawIndex;
525
+ this.cursorPos = state.cursorPos;
526
+ this.interlacedSyncAndVideo = state.interlacedSyncAndVideo;
527
+ this.screenSubtract = state.screenSubtract;
528
+ this.ula.restoreState(state.ula);
529
+ this.crtc.restoreState(state.crtc);
530
+ this.teletext.restoreState(state.teletext);
531
+ // Restore ulaPal after ULA restore, since ULA recomputation may overwrite it
532
+ this.ulaPal.set(state.ulaPal);
533
+ }
534
+
252
535
  reset(cpu, via) {
253
536
  this.cpu = cpu;
254
537
  this.sysvia = via;
@@ -320,16 +603,20 @@ export class Video {
320
603
  destOffset |= 0;
321
604
  const offset = table4bppOffset(this.ulaMode, dat);
322
605
  const fb32 = this.fb32;
323
- const ulaPal = this.ulaPal;
606
+ // In NULA palette mode, bypass the ULA palette (ulaPal) and look up
607
+ // pixel colours directly from the NULA 12-bit colour table (collook).
608
+ // This skips the XOR-7 logical↔physical colour mapping that the
609
+ // standard ULA applies. Reference: b-em src/video.c lines 1083, 1117.
610
+ const colourLookup = this.ula.paletteMode ? this.ula.collook : this.ulaPal;
324
611
  const table4bpp = this.table4bpp;
325
612
  // Take advantage of numPixels being either 8 or 16
326
613
  if (numPixels === 8) {
327
614
  for (let i = 0; i < 8; ++i) {
328
- fb32[destOffset + i] = ulaPal[table4bpp[offset + i]];
615
+ fb32[destOffset + i] = colourLookup[table4bpp[offset + i]];
329
616
  }
330
617
  } else {
331
618
  for (let i = 0; i < 16; ++i) {
332
- fb32[destOffset + i] = ulaPal[table4bpp[offset + i]];
619
+ fb32[destOffset + i] = colourLookup[table4bpp[offset + i]];
333
620
  }
334
621
  }
335
622
  }
@@ -656,9 +943,11 @@ export class Video {
656
943
  // Read data from address pointer if both horizontal and vertical display enabled.
657
944
  const dat = this.readVideoMem();
658
945
  if (insideBorder) {
659
- if (this.teletextMode) {
660
- this.teletext.fetchData(dat);
661
- }
946
+ // Always feed the SAA5050 pipeline: on real hardware IC15
947
+ // permanently connects the video bus to the SAA5050 inputs
948
+ // regardless of ULA mode. Required for the "TTX trick".
949
+ // See https://github.com/mattgodbolt/jsbeeb/issues/546
950
+ this.teletext.fetchData(dat);
662
951
 
663
952
  // Check cursor start.
664
953
  if (
@@ -691,7 +980,17 @@ export class Video {
691
980
 
692
981
  if ((this.dispEnabled & EVERYTHINGENABLED) === EVERYTHINGENABLED) {
693
982
  if (this.teletextMode) {
694
- this.teletext.render(this.fb32, offset);
983
+ if (this.halfClock) {
984
+ // Proper MODE 7 (1MHz clock + teletext): render SAA5050 output normally.
985
+ this.teletext.render(this.fb32, offset);
986
+ } else {
987
+ // 2MHz clock + teletext bit set (the "TTX trick"): the Video ULA
988
+ // forces DISPEN to the SAA5050 to 0 in 2MHz modes (0/1/2/3), so
989
+ // the SAA5050 outputs black. Confirmed by Rich Talbot-Watkins (RTW)
990
+ // at ABUG 2026-03-13.
991
+ // See https://github.com/mattgodbolt/jsbeeb/issues/546
992
+ this.fb32.fill(OPAQUE_BLACK, offset, offset + this.pixelsPerChar);
993
+ }
695
994
  } else {
696
995
  this.blitFb(dat, offset, this.pixelsPerChar, doubledLines);
697
996
  }
@@ -705,6 +1004,14 @@ export class Video {
705
1004
  }
706
1005
  }
707
1006
 
1007
+ // IC37/IC36: during H blanking with V display active, always feed
1008
+ // the SAA5050 pipeline with the video bus data, forcing bit 6 high.
1009
+ // On real hardware IC37/IC36 operates regardless of ULA mode —
1010
+ // it is wired to the CRTC DISPEN signal, not the ULA teletext bit.
1011
+ if (!(this.dispEnabled & HDISPENABLE) && this.dispEnabled & VDISPENABLE) {
1012
+ this.teletext.fetchData(this.readVideoMem() | 0x40);
1013
+ }
1014
+
708
1015
  // CRTC MA always increments, inside display border or not.
709
1016
  this.addr = (this.addr + 1) & 0x3fff;
710
1017
 
@@ -777,11 +1084,19 @@ export class Video {
777
1084
 
778
1085
  export class FakeVideo {
779
1086
  constructor() {
780
- this.ula = this.crtc = {
1087
+ this.crtc = {
1088
+ read: function () {
1089
+ return 0xff;
1090
+ },
1091
+ write: utils.noop,
1092
+ };
1093
+ this.ula = {
781
1094
  read: function () {
782
1095
  return 0xff;
783
1096
  },
784
1097
  write: utils.noop,
1098
+ reset: utils.noop,
1099
+ disabled: false,
785
1100
  };
786
1101
  this.regs = new Uint8Array(32);
787
1102
  }
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({