jsbeeb 1.19.3 → 1.20.1

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/soundchip.js CHANGED
@@ -12,6 +12,10 @@ export const SoundBufferSamples = 512;
12
12
  // mean level, which a zero-mean output would silence (see issue #863).
13
13
  const DcRestoreCornerHz = 1 / (2 * Math.PI * 10e3 * 4.7e-6);
14
14
 
15
+ // A chip with an event sink reports progress this often, so its events can
16
+ // stream through a long execute() rather than all arriving at its end.
17
+ const EventProgressCycles = 4000;
18
+
15
19
  const volumeTable = new Float32Array(16);
16
20
  (() => {
17
21
  let f = 1.0;
@@ -35,9 +39,15 @@ export class SoundChip {
35
39
  * @param {function(Float32Array): void} onBuffer called with each full
36
40
  * SoundBufferSamples-sized buffer of output. Receives the same buffer
37
41
  * every call, overwritten afterwards: copy the contents if they are kept.
42
+ * @param {object} [options]
43
+ * @param {function(object): void} [options.onEvent] called with each
44
+ * change to the chip's state, stamped with the cycle it takes effect at.
45
+ * A chip with an event sink does not render; something else renders
46
+ * from the events (see audio-renderer.js).
38
47
  */
39
- constructor(onBuffer) {
48
+ constructor(onBuffer, { onEvent = null } = {}) {
40
49
  this._onBuffer = onBuffer;
50
+ this._onEvent = onEvent;
41
51
  // 4MHz input signal. Internal divide-by-8
42
52
  this.soundchipFreq = 4000000.0 / 8;
43
53
  const sampleRate = this.soundchipFreq;
@@ -91,15 +101,38 @@ export class SoundChip {
91
101
  mute: () => {
92
102
  this.catchUp();
93
103
  this.sineOn = false;
104
+ this._emit({ sine: 0 });
94
105
  },
95
106
  tone: (freq) => {
96
107
  this.catchUp();
97
108
  this.sineOn = true;
98
109
  this.sineStep = (freq / sampleRate) * this.sineTable.length;
110
+ this._emit({ sine: freq });
99
111
  },
100
112
  };
101
113
  }
102
114
 
115
+ _emit(event) {
116
+ if (this._onEvent) this._onEvent({ cycle: this.scheduler.epoch, ...event });
117
+ }
118
+
119
+ /** Applies an event from another chip's onEvent, at the cycle this chip is rendering. */
120
+ applyEvent(event) {
121
+ if (event.poke !== undefined) this.poke(event.poke);
122
+ else if (event.sine !== undefined) {
123
+ if (event.sine) this.toneGenerator.tone(event.sine);
124
+ else this.toneGenerator.mute();
125
+ } else if (event.enabled !== undefined) this.enabled = event.enabled;
126
+ else if (event.state !== undefined) this.restoreState(event.state);
127
+ else if (event.reset !== undefined) this.reset(event.reset);
128
+ }
129
+
130
+ /** Renders `length` samples of output from `cycle`, for a chip that is driven by events. */
131
+ renderAt(cycle, out, offset, length) {
132
+ this.scheduler.epoch = this.lastRunEpoch = cycle;
133
+ this.generate(out, offset, length);
134
+ }
135
+
103
136
  sineChannel(channel, out, offset, length) {
104
137
  if (!this.sineOn) return;
105
138
 
@@ -181,6 +214,7 @@ export class SoundChip {
181
214
  this.volume[2] = volumeTable[v2];
182
215
  this.volume[3] = volumeTable[v3];
183
216
  this.noisePoked();
217
+ this._emit({ state: this.snapshotState() });
184
218
  }
185
219
 
186
220
  generate(out, offset, length) {
@@ -219,6 +253,13 @@ export class SoundChip {
219
253
  this.activeTask = this.scheduler.newTask(() => {
220
254
  if (this.active) this.poke(this.slowDataBus);
221
255
  });
256
+ if (this._onEvent) {
257
+ this.progressTask = this.scheduler.newTask(() => {
258
+ this._emit({ progress: true });
259
+ this.progressTask.schedule(EventProgressCycles);
260
+ });
261
+ this.progressTask.schedule(EventProgressCycles);
262
+ }
222
263
  }
223
264
 
224
265
  render(out, offset, length) {
@@ -239,6 +280,7 @@ export class SoundChip {
239
280
  }
240
281
 
241
282
  advance(cycles) {
283
+ if (this._onEvent) return;
242
284
  const num = cycles * this.samplesPerCycle + this.residual;
243
285
  let rounded = num | 0;
244
286
  this.residual = num - rounded;
@@ -270,6 +312,7 @@ export class SoundChip {
270
312
 
271
313
  poke(value) {
272
314
  this.catchUp();
315
+ this._emit({ poke: value });
273
316
 
274
317
  let command;
275
318
  if (value & 0x80) {
@@ -350,10 +393,13 @@ export class SoundChip {
350
393
  // Older snapshots predate the DC blocker
351
394
  this.dcPrevIn = state.dcPrevIn ?? 0;
352
395
  this.dcPrevOut = state.dcPrevOut ?? 0;
396
+ this.progressTask?.ensureScheduled(true, EventProgressCycles);
397
+ this._emit({ state: this.snapshotState() });
353
398
  }
354
399
 
355
400
  reset(hard) {
356
401
  if (!hard) return;
402
+ this._emit({ reset: true });
357
403
  for (let i = 0; i < 4; ++i) {
358
404
  this.counter[i] = 0;
359
405
  this.registers[i] = 0;
@@ -366,14 +412,15 @@ export class SoundChip {
366
412
 
367
413
  enable(e) {
368
414
  this.enabled = e;
415
+ this._emit({ enabled: e });
369
416
  }
370
417
 
371
418
  mute() {
372
- this.enabled = false;
419
+ this.enable(false);
373
420
  }
374
421
 
375
422
  unmute() {
376
- this.enabled = true;
423
+ this.enable(true);
377
424
  }
378
425
  }
379
426
 
@@ -383,8 +430,8 @@ export class SoundChip {
383
430
  * channel with DC-blocking filter.
384
431
  */
385
432
  export class AtomSoundChip extends SoundChip {
386
- constructor(onBuffer, { cpuSpeed = 1000000 } = {}) {
387
- super(onBuffer);
433
+ constructor(onBuffer, { cpuSpeed = 1000000, onEvent = null } = {}) {
434
+ super(onBuffer, { onEvent });
388
435
  this.samplesPerCycle = this.soundchipFreq / cpuSpeed;
389
436
  this.secondsPerCycle = 1 / cpuSpeed;
390
437
 
@@ -423,7 +470,19 @@ export class AtomSoundChip extends SoundChip {
423
470
  this._speakerCycleOffset = 0;
424
471
  }
425
472
 
473
+ applyEvent(event) {
474
+ if (event.bit !== undefined) this.bitChange.push({ bit: event.bit, cycles: event.cycle });
475
+ else if (event.speakerReset !== undefined) this.speakerReset();
476
+ else super.applyEvent(event);
477
+ }
478
+
479
+ renderAt(cycle, out, offset, length) {
480
+ this._speakerCycleOffset = 0;
481
+ super.renderAt(cycle, out, offset, length);
482
+ }
483
+
426
484
  speakerReset() {
485
+ this._emit({ speakerReset: true });
427
486
  this.bitChange = [];
428
487
  this.currentSpeakerBit = 0.0;
429
488
  this._speakerPrevIn = 0;
@@ -461,7 +520,9 @@ export class AtomSoundChip extends SoundChip {
461
520
 
462
521
  updateSpeaker(value, microCycle, seconds) {
463
522
  const cycles = microCycle + seconds / this.secondsPerCycle;
464
- this.bitChange.push({ bit: value ? 1.0 : 0.0, cycles });
523
+ const bit = value ? 1.0 : 0.0;
524
+ if (this._onEvent) this._onEvent({ cycle: cycles, bit });
525
+ else this.bitChange.push({ bit, cycles });
465
526
  }
466
527
  }
467
528
 
package/src/tapes.js CHANGED
@@ -315,6 +315,5 @@ export async function loadTapeFromData(name, data, model) {
315
315
  console.log("Detected a UEF tape");
316
316
  return new UefTape(stream, model);
317
317
  }
318
- console.log("Unknown tape format");
319
- return null;
318
+ throw new Error(`${name} is not a UEF or tapefile tape image`);
320
319
  }
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Simulates PAL composite video artifacts by encoding the framebuffer to a
6
6
  // composite signal and decoding it back to RGB, mimicking the behavior of
7
- // connecting a BBC Micro to a PAL television via composite cable.
7
+ // a BBC Micro's UHF-modulated picture on a PAL television.
8
8
  //
9
9
  // REFERENCES:
10
10
  // - John Watkinson's "Engineer's Guide to Decoding & Encoding" (Section 3.4)
@@ -40,7 +40,7 @@ export class PALCompositeFilter {
40
40
  uFramebuffer: gl.getUniformLocation(this.program, "uFramebuffer"),
41
41
  uResolution: gl.getUniformLocation(this.program, "uResolution"),
42
42
  uTexelSize: gl.getUniformLocation(this.program, "uTexelSize"),
43
- uFrameCount: gl.getUniformLocation(this.program, "uFrameCount"),
43
+ uLineBase: gl.getUniformLocation(this.program, "uLineBase"),
44
44
  };
45
45
  }
46
46
 
@@ -55,6 +55,6 @@ export class PALCompositeFilter {
55
55
  gl.uniform1i(this.locations.uFramebuffer, 0); // Texture unit 0
56
56
  gl.uniform2f(this.locations.uResolution, params.width, params.height);
57
57
  gl.uniform2f(this.locations.uTexelSize, 1.0 / params.width, 1.0 / params.height);
58
- gl.uniform1f(this.locations.uFrameCount, params.frameCount % 8); // 8-field temporal phase sequence
58
+ gl.uniform2f(this.locations.uLineBase, params.lineBaseEven, params.lineBaseOdd);
59
59
  }
60
60
  }
@@ -5,23 +5,21 @@ varying vec2 vTexCoord;
5
5
  uniform sampler2D uFramebuffer;
6
6
  uniform vec2 uResolution;
7
7
  uniform vec2 uTexelSize;
8
- uniform float uFrameCount;
8
+ // Line numbers of texture rows 0 (x) and 1 (y)
9
+ uniform vec2 uLineBase;
9
10
 
10
11
  const float PI = 3.14159265359;
11
12
 
12
13
  // IMPLEMENTATION (Baseband Blending Method):
13
14
  // 1. Encode RGB to PAL composite: Y + U*sin(ωt) + V*cos(ωt)*v_switch
14
15
  // 2. Demodulate current line (with correct phase) → U_curr, V_curr
15
- // 3. Demodulate previous line (2H for interlaced, same field) → U_prev, V_prev
16
+ // 3. Demodulate the previous scanline (two texture rows up) → U_prev, V_prev
16
17
  // 4. Blend at baseband: U_final = mix(U_curr, U_prev), V_final = mix(V_curr, V_prev)
17
- // 5. Remodulate blended chroma back to composite frequency
18
- // 6. Extract luma via complementary subtraction: Y = composite - remodulated_chroma
19
- // 7. Combine luma and chroma, convert back to RGB
18
+ // 5. Luma: composite through the set's low-pass and subcarrier trap
19
+ // 6. Combine luma and chroma, convert back to RGB
20
20
  //
21
- // NOTE: Uses 2H delay (line-2) not 1H (line-1) because jsbeeb simulates interlacing by
22
- // rendering only odd or even lines per frame. A real PAL TV's 1H delay line would contain
23
- // the previous scanline from the SAME field, which is 2 texture lines apart. Proper
24
- // support for non-interlaced modes needs to be added.
21
+ // NOTE: Texture rows are half-lines, so the previous scanline of the same field is two rows
22
+ // up, and the subcarrier phase and V-switch come from uLineBase, not from the row.
25
23
 
26
24
  // Chroma demodulation gain: compensates for sin²(x) = 0.5 - 0.5·cos(2x) amplitude loss
27
25
  const float FIR_GAIN = 2.0;
@@ -35,13 +33,9 @@ const float PAL_FRAME_RATE = 25.0; // Frames per second
35
33
  const float PAL_SUBCARRIER_MHZ = 4.43361875; // PAL color subcarrier frequency (exact)
36
34
 
37
35
  // Derived PAL parameters
38
- const float PAL_LINES_PER_FIELD = PAL_TOTAL_LINES / 2.0;
39
36
  const float PAL_CYCLES_PER_LINE = PAL_SUBCARRIER_MHZ * 1e6 / (PAL_TOTAL_LINES * PAL_FRAME_RATE);
40
- const float PAL_LINE_PHASE_OFFSET = fract(PAL_CYCLES_PER_LINE);
41
- const float PAL_FIELD_PHASE_OFFSET = PAL_LINE_PHASE_OFFSET * PAL_LINES_PER_FIELD;
42
-
43
- // jsbeeb texture parameters
44
- const float TEXTURE_WIDTH = 1024.0; // Framebuffer width (896 visible + 128 blanking)
37
+ // fract(PAL_CYCLES_PER_LINE), spelt out: float keeps more of it alone than inside 283.7516
38
+ const float PAL_LINE_PHASE_OFFSET = 0.7516;
45
39
 
46
40
  // RGB → YUV conversion with proper PAL signal levels baked in
47
41
  // Derived from ITU-R BT.470-6: white at 0.7V, peak at 0.931V
@@ -63,45 +57,58 @@ vec3 yuv_to_rgb(vec3 yuv) {
63
57
  );
64
58
  }
65
59
 
66
- // Demodulate composite signal at given position
67
- vec2 demodulate_uv(vec2 xy, float pixel_x, float offset_pixels, float v_switch, float cycles_per_pixel, float phase_offset) {
68
- float t = ((pixel_x + offset_pixels) * cycles_per_pixel + phase_offset) * 2.0 * PI;
60
+ // Subcarrier phase (radians) at offset_pixels from pixel_x
61
+ float carrier_phase(float pixel_x, float offset_pixels, float cycles_per_pixel, float phase_offset) {
62
+ return ((pixel_x + offset_pixels) * cycles_per_pixel + phase_offset) * 2.0 * PI;
63
+ }
69
64
 
65
+ // Encode the texel at offset_pixels from xy to composite: Y + U*sin(ωt) + V*cos(ωt)*v_switch
66
+ float encode_composite(vec2 xy, float offset_pixels, float t, float v_switch) {
70
67
  vec2 sample_uv = xy + vec2(offset_pixels * uTexelSize.x, 0.0);
71
68
  vec3 rgb = texture2D(uFramebuffer, sample_uv).rgb;
72
69
  vec3 yuv = rgb_to_yuv(rgb);
70
+ return yuv.x + yuv.y * sin(t) + yuv.z * cos(t) * v_switch;
71
+ }
73
72
 
74
- // Encode to composite: Y + U*sin(ωt) + V*cos(ωt)*v_switch
75
- float composite = yuv.x + yuv.y * sin(t) + yuv.z * cos(t) * v_switch;
73
+ // Demodulate composite signal at given position
74
+ vec2 demodulate_uv(vec2 xy, float pixel_x, float offset_pixels, float v_switch, float cycles_per_pixel, float phase_offset) {
75
+ float t = carrier_phase(pixel_x, offset_pixels, cycles_per_pixel, phase_offset);
76
+ float composite = encode_composite(xy, offset_pixels, t, v_switch);
76
77
 
77
78
  // Demodulate: multiply by carrier to shift chroma to baseband
78
79
  return vec2(composite * sin(t), composite * cos(t) * v_switch);
79
80
  }
80
81
 
81
82
  void main() {
82
- // Use gl_FragCoord for pixel coordinates - it's hardware-provided and avoids interpolation artifacts
83
- vec2 pixelCoord = vec2(gl_FragCoord.x, uResolution.y - gl_FragCoord.y);
83
+ // Texel column and row, whatever size the drawing buffer is.
84
+ vec2 pixelCoord = floor(vTexCoord * uResolution);
84
85
 
85
86
  // BEGIN_FIR_COEFFICIENTS
86
87
  // This section is replaced by the Vite build to include FIR filter coefficients.
87
88
  // Change Cutoff (in comment below) or FIRTAPS value to configure.
88
- // Cutoff: 1.108 MHz (quarter subcarrier)
89
+ // Cutoff: 1.108 MHz (quarter subcarrier; the -6 dB design point, -3 dB at about 0.83 MHz)
89
90
  const int FIRTAPS = 21;
90
91
  float FIR[FIRTAPS];
91
92
  // END_FIR_COEFFICIENTS
92
93
 
93
- float line = floor(pixelCoord.y);
94
+ // BEGIN_LUMA_COEFFICIENTS
95
+ // This section is replaced by the Vite build with the set's luma path, low-pass and
96
+ // subcarrier trap in one symmetric FIR, as designed in tools/luma-fir-generator.js.
97
+ const int LUMA_TAPS = 31;
98
+ float LUMA_FIR[LUMA_TAPS];
99
+ // END_LUMA_COEFFICIENTS
100
+
101
+ float row = pixelCoord.y;
102
+ float line = (mod(row, 2.0) < 1.0 ? uLineBase.x : uLineBase.y) + floor(row / 2.0);
94
103
 
95
104
  // PAL phase alternates each scanline (V component inverts)
96
105
  float v_switch = mod(line, 2.0) < 1.0 ? 1.0 : -1.0;
97
106
 
98
- // Map PAL subcarrier across texture width
99
- float cycles_per_pixel = PAL_CYCLES_PER_LINE / TEXTURE_WIDTH;
107
+ // Map PAL subcarrier across the full line, blanking included
108
+ float cycles_per_pixel = PAL_CYCLES_PER_LINE / uResolution.x;
100
109
 
101
- // PAL temporal phase (8-field sequence creates animated dot crawl)
102
- float line_phase_offset = line * PAL_LINE_PHASE_OFFSET;
103
- float frame_phase_offset = uFrameCount * PAL_FIELD_PHASE_OFFSET;
104
- float phase_offset = line_phase_offset + frame_phase_offset;
110
+ // Subcarrier phase at the start of this line, carried across frames by the line count
111
+ float phase_offset = fract(line * PAL_LINE_PHASE_OFFSET);
105
112
 
106
113
  // Step 1: Demodulate current line with FIR filter
107
114
  vec2 filtered_uv_curr = vec2(0.0);
@@ -111,14 +118,11 @@ void main() {
111
118
  filtered_uv_curr += FIR_GAIN * uv * FIR[i];
112
119
  }
113
120
 
114
- // Step 2: Demodulate previous line (2H for interlaced, same field) with FIR filter
115
- // In interlaced mode, only odd OR even lines are rendered per frame.
116
- // Using 2H (line-2) ensures we sample from the same field (both fresh data).
117
- // This represents the TV's 1H delay within a single field.
121
+ // Step 2: Demodulate the previous scanline of the same field (two rows up, one line
122
+ // earlier) with FIR filter. This represents the TV's 1H delay line.
118
123
  vec2 prev_uv = vTexCoord - vec2(0.0, 2.0 * uTexelSize.y);
119
- float prev_line = line - 2.0;
120
- float prev_v_switch = v_switch * -1.0;
121
- float prev_phase_offset = prev_line * PAL_LINE_PHASE_OFFSET + frame_phase_offset;
124
+ float prev_v_switch = -v_switch;
125
+ float prev_phase_offset = fract((line - 1.0) * PAL_LINE_PHASE_OFFSET);
122
126
 
123
127
  vec2 filtered_uv_prev = vec2(0.0);
124
128
  for (int i = 0; i < FIRTAPS; i++) {
@@ -130,17 +134,13 @@ void main() {
130
134
  // Step 3: Blend chroma at baseband
131
135
  vec2 filtered_uv = mix(filtered_uv_curr, filtered_uv_prev, CHROMA_BLEND_WEIGHT);
132
136
 
133
- // Step 4: Get luma via complementary subtraction
134
- float t_curr = (pixelCoord.x * cycles_per_pixel + phase_offset) * 2.0 * PI;
135
- vec3 rgb_curr = texture2D(uFramebuffer, vTexCoord).rgb;
136
- vec3 yuv_curr = rgb_to_yuv(rgb_curr);
137
- float composite_curr = yuv_curr.x + yuv_curr.y * sin(t_curr) + yuv_curr.z * cos(t_curr) * v_switch;
138
-
139
- // Remodulate blended chroma back to composite frequency
140
- float remodulated_chroma = filtered_uv.x * sin(t_curr) + filtered_uv.y * cos(t_curr) * v_switch;
141
-
142
- // Complementary subtraction: luma = composite - chroma
143
- float y_out = composite_curr - remodulated_chroma;
137
+ // Step 4: Luma is the composite through the set's low-pass and subcarrier trap
138
+ float y_out = 0.0;
139
+ for (int i = 0; i < LUMA_TAPS; i++) {
140
+ float offset = float(i - (LUMA_TAPS - 1) / 2);
141
+ float t = carrier_phase(pixelCoord.x, offset, cycles_per_pixel, phase_offset);
142
+ y_out += encode_composite(vTexCoord, offset, t, v_switch) * LUMA_FIR[i];
143
+ }
144
144
 
145
145
  vec3 rgb_out = yuv_to_rgb(vec3(y_out, filtered_uv.x, filtered_uv.y));
146
146
  gl_FragColor = vec4(clamp(rgb_out, 0.0, 1.0), 1.0);
package/src/video.js CHANGED
@@ -19,6 +19,10 @@ export const OPAQUE_WHITE = 0xffffffff;
19
19
 
20
20
  export const MinPaintedFrameRows = 64;
21
21
 
22
+ // The PAL subcarrier advances 283.7516 cycles per line (4433618.75 Hz / 15625 Hz), and
23
+ // 0.7516 = 1879 / 2500, so its phase against the line is periodic in 2500 lines.
24
+ export const PalPhasePeriodLines = 2500;
25
+
22
26
  ////////////////////
23
27
  // VideoNULA - programmable 12-bit RGB palette extension (RobC hardware mod).
24
28
  // Reference: b-em src/video.c (stardot/b-em).
@@ -348,6 +352,11 @@ export class Video {
348
352
  this.bitmapY = 0;
349
353
  this.oddClock = false;
350
354
  this.frameCount = 0;
355
+ this.hsyncCount = 0;
356
+ // The hsyncCount in effect while framebuffer rows 0 and 1 were drawn: row r was drawn
357
+ // during line lineBase[r & 1] + (r >> 1), which sets its PAL subcarrier phase.
358
+ this.lineBaseEven = 0;
359
+ this.lineBaseOdd = 0;
351
360
  this.doEvenFrameLogic = false;
352
361
  this.isEvenRender = true;
353
362
  this.lastRenderWasEven = false;
@@ -460,6 +469,9 @@ export class Video {
460
469
  bitmapY: this.bitmapY,
461
470
  oddClock: this.oddClock,
462
471
  frameCount: this.frameCount,
472
+ hsyncCount: this.hsyncCount,
473
+ lineBaseEven: this.lineBaseEven,
474
+ lineBaseOdd: this.lineBaseOdd,
463
475
  doEvenFrameLogic: this.doEvenFrameLogic,
464
476
  isEvenRender: this.isEvenRender,
465
477
  lastRenderWasEven: this.lastRenderWasEven,
@@ -512,6 +524,9 @@ export class Video {
512
524
  this.bitmapY = state.bitmapY;
513
525
  this.oddClock = state.oddClock;
514
526
  this.frameCount = state.frameCount;
527
+ this.hsyncCount = state.hsyncCount ?? 0;
528
+ this.lineBaseEven = state.lineBaseEven ?? 0;
529
+ this.lineBaseOdd = state.lineBaseOdd ?? 0;
515
530
  this.doEvenFrameLogic = state.doEvenFrameLogic;
516
531
  this.isEvenRender = state.isEvenRender;
517
532
  this.lastRenderWasEven = state.lastRenderWasEven;
@@ -606,13 +621,26 @@ export class Video {
606
621
  this.dispEnabled |= enable;
607
622
  this.cursorInvertedOffset = -1;
608
623
 
609
- this.bitmapY = 0;
610
624
  // Interlace even frame fires vsync midway through a scanline.
611
- if (!!(this.regs[8] & 1) && !!(this.frameCount & 1)) {
612
- this.bitmapY = -1;
625
+ const oddField = !!(this.regs[8] & 1) && !!(this.frameCount & 1);
626
+ this.bitmapY = oddField ? -1 : 0;
627
+
628
+ // The even field draws the rest of the vsync line on row 0; the odd field's first row
629
+ // is the first hsync after it.
630
+ const firstRowLine = (this.hsyncCount + (oddField ? 1 : 0)) % PalPhasePeriodLines;
631
+ if (this.doublesLines()) {
632
+ this.lineBaseEven = this.lineBaseOdd = firstRowLine;
633
+ } else if (oddField) {
634
+ this.lineBaseOdd = firstRowLine;
635
+ } else {
636
+ this.lineBaseEven = firstRowLine;
613
637
  }
614
638
  }
615
639
 
640
+ doublesLines() {
641
+ return (this.doubledScanlines && !this.interlacedSyncAndVideo) || this.isEvenRender === this.lastRenderWasEven;
642
+ }
643
+
616
644
  debugOffset(x, y) {
617
645
  if (x < 0 || x >= 1024) return -1;
618
646
  if (y < 0 || y >= 768) return -1;
@@ -715,9 +743,7 @@ export class Video {
715
743
  if (!this.halfClock || !this.oddClock) return;
716
744
  if ((this.dispEnabled & EVERYTHINGENABLED) !== EVERYTHINGENABLED) return;
717
745
  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;
746
+ const doubledLines = this.doublesLines();
721
747
  const bitmapRow = doubledLines ? this.bitmapY & ~1 : this.bitmapY;
722
748
  const offset = bitmapRow * 1024 + this.bitmapX;
723
749
  const halfCell = this.pixelsPerChar >>> 1;
@@ -903,6 +929,7 @@ export class Video {
903
929
  // The CRT vertical beam speed is constant, so this is actually
904
930
  // an approximation that works if hsyncs are spaced evenly.
905
931
  this.bitmapY += 2;
932
+ this.hsyncCount = (this.hsyncCount + 1) % PalPhasePeriodLines;
906
933
 
907
934
  // Arbitrary moment when TV will give up and start flyback in the absence of an explicit VSync signal
908
935
  return this.bitmapY >= 768;
@@ -1064,7 +1091,8 @@ export class Video {
1064
1091
  // There's a painting subtlety here: if we're in an
1065
1092
  // interlace mode but R6>R4 then we'll get stuck
1066
1093
  // painting just an odd or even frame, so we double up
1067
- // scanlines to avoid a ghost half frame.
1094
+ // scanlines to avoid a ghost half frame. This is
1095
+ // doublesLines(), inlined for speed.
1068
1096
  if (
1069
1097
  (this.doubledScanlines && !this.interlacedSyncAndVideo) ||
1070
1098
  this.isEvenRender === this.lastRenderWasEven