jsbeeb 1.15.0 → 1.16.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/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.15.0",
10
+ "version": "1.16.0",
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"
@@ -54,10 +54,6 @@
54
54
  "electron-builder": "^26.15.3",
55
55
  "electron-store": "^11.0.2"
56
56
  },
57
- "//overrides": "brace-expansion forced to the patched version for GHSA-mh99-v99m-4gvg; the electron-builder dependency chain still pins vulnerable versions. Remove once `npm audit` is clean without it.",
58
- "overrides": {
59
- "brace-expansion": "^5.0.8"
60
- },
61
57
  "license": "GPL-3.0-or-later",
62
58
  "build": {
63
59
  "appId": "org.godbolt.bbc",
@@ -99,15 +95,21 @@
99
95
  "test:unit": "vitest run --silent=true tests/unit",
100
96
  "test:integration": "vitest run --silent=true tests/integration",
101
97
  "test:cpu": "node tests/test-suite.js",
98
+ "test:shader": "vitest run --silent=true tests/shader",
102
99
  "coverage:unit": "vitest run tests/unit --coverage",
103
100
  "coverage:all-tests": "vitest run --coverage",
104
101
  "benchmark": "node src/app-bench.js",
102
+ "mirror-sth": "node tools/mirror-sth.js --out .sth-mirror",
103
+ "mirror-sth:check": "node tools/mirror-sth.js --index-only",
104
+ "mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
105
+ "mirror-sth:upload:blobs": "aws s3 sync .sth-mirror s3://bbc.xania.org/archive/sth/ --no-progress --exclude '*manifest.json' --exclude 'meta/*' --cache-control 'public, max-age=31536000, immutable'",
106
+ "mirror-sth:upload:index": "aws s3 sync .sth-mirror s3://bbc.xania.org/archive/sth/ --no-progress --exclude '*' --include '*manifest.json' --include 'meta/*' --cache-control 'public, max-age=300'",
105
107
  "electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
106
108
  "electron:build": "electron-builder"
107
109
  },
108
110
  "lint-staged": {
109
111
  "*.js": [
110
- "vitest related --run --exclude tests/integration --no-file-parallelism",
112
+ "vitest related --run --exclude tests/integration --exclude tests/shader --no-file-parallelism",
111
113
  "eslint --cache --fix",
112
114
  "prettier --write"
113
115
  ],
package/src/6502.js CHANGED
@@ -213,6 +213,9 @@ class Base6502 {
213
213
  if (this._nmiLevel && !prevLevel) this._nmiEdge = true;
214
214
  }
215
215
 
216
+ /** Called as the NMI vector is taken, for devices that withdraw their request at that point. */
217
+ nmiAcknowledged() {}
218
+
216
219
  polltime() {
217
220
  throw new Error("Must be overridden");
218
221
  }
@@ -239,6 +242,7 @@ class Base6502 {
239
242
  if ((this.model.nmos || isIrq) && this._nmiEdge) {
240
243
  vector = 0xfffa;
241
244
  this._nmiEdge = false;
245
+ this.nmiAcknowledged();
242
246
  }
243
247
  this.takeInt = false;
244
248
  this.pc = this.readmem(vector) | (this.readmem(vector + 1) << 8);
@@ -451,6 +455,10 @@ class Tube6502 extends Base6502 {
451
455
  return this.memory[offset & 0xffff];
452
456
  }
453
457
 
458
+ nmiAcknowledged() {
459
+ this.tube.acknowledgeNmi();
460
+ }
461
+
454
462
  writemem(addr, b) {
455
463
  if ((addr & 0xfff8) === 0xfef8) {
456
464
  return this.tube.parasiteWrite(addr, b);
@@ -666,7 +674,7 @@ export class Cpu6502 extends Base6502 {
666
674
  this.resetLine = true;
667
675
  this.cpuMultiplier = this.config.cpuMultiplier;
668
676
  this.videoCyclesBatch = this.config.videoCyclesBatch | 0;
669
- this.peripheralCyclesPerSecond = 2 * 1000 * 1000;
677
+ this.peripheralCyclesPerSecond = model.cyclesPerSecond;
670
678
  this.hasTube = !!this.config.tube;
671
679
  this.hasMusic5000 = !!this.config.hasMusic5000;
672
680
  this.hasTeletextAdaptor = !!this.config.hasTeletextAdaptor;
@@ -1361,7 +1369,7 @@ export class Cpu6502 extends Base6502 {
1361
1369
  this.fdc.powerOnReset();
1362
1370
  this.adconverter.reset();
1363
1371
 
1364
- this.touchScreen = new TouchScreen(this.scheduler);
1372
+ this.touchScreen = new TouchScreen(this.scheduler, this.model.cyclesPerSecond);
1365
1373
  if (this.econet) this.filestore = new Filestore(this, this.econet);
1366
1374
  }
1367
1375
 
@@ -1478,9 +1486,10 @@ export class Cpu6502 extends Base6502 {
1478
1486
  // that from both, to keep the domain low (while accumulating seconds). Take care to preserve the bottom
1479
1487
  // bit though; as that encodes whether we're on an even or odd bus cycle.
1480
1488
  const smaller = Math.min(this.targetCycles, this.currentCycles) & 0xfffffffe;
1481
- if (smaller >= 2 * 1000 * 1000) {
1482
- this.targetCycles -= 2 * 1000 * 1000;
1483
- this.currentCycles -= 2 * 1000 * 1000;
1489
+ const cyclesPerSecond = this.model.cyclesPerSecond;
1490
+ if (smaller >= cyclesPerSecond) {
1491
+ this.targetCycles -= cyclesPerSecond;
1492
+ this.currentCycles -= cyclesPerSecond;
1484
1493
  this.cycleSeconds++;
1485
1494
  }
1486
1495
  // Any tracing or debugging means we need to run the potentially slower version: the debug read or
@@ -1614,8 +1623,6 @@ export class AtomCpu6502 extends Cpu6502 {
1614
1623
  this.ramRomOs = expanded;
1615
1624
  }
1616
1625
 
1617
- // Atom runs at 1 MHz
1618
- this.peripheralCyclesPerSecond = 1 * 1000 * 1000;
1619
1626
  // reset() and debugger.setCpu() are called by initialise() after loadOs().
1620
1627
  }
1621
1628
 
package/src/6847.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  import * as utils from "./utils.js";
3
3
  import * as fontData from "./6847_fontdata.js";
4
+ import { encodeLineGrid } from "./video-filters/pixel-grid.js";
4
5
 
5
6
  const VDISPENABLE = 1 << 0,
6
7
  HDISPENABLE = 1 << 1,
@@ -111,6 +112,11 @@ only 1 bit is used of SG6 - to get yellow/red, cyan/orange
111
112
  // constant for the graphics mode
112
113
  const MODE_AG = 0x10; // graphics mode
113
114
 
115
+ // The MC6847 datasheet specifies 3.579545 MHz, but 3.638004 is used here as an
116
+ // empirical correction to align VSync/interrupt timing with the CPU clock.
117
+ // TODO: revisit once full integration is testable.
118
+ const VdgClockMhz = 3.638004;
119
+
114
120
  export class Video6847 {
115
121
  constructor(video) {
116
122
  this.video = video;
@@ -175,6 +181,7 @@ export class Video6847 {
175
181
  this.bitmapPxPerPixel = 2; // each pixel is 2 bitmap pixels wide and high
176
182
  this.pixelsPerBit = this.bitmapPxPerPixel;
177
183
  this.bpp = 1;
184
+ this.updateLineGrid();
178
185
 
179
186
  this.cpuAddr = 0;
180
187
  this.dispEnabled = 0;
@@ -225,6 +232,9 @@ export class Video6847 {
225
232
  reset(cpu, ppia) {
226
233
  this.cpu = cpu;
227
234
  this.ppia = ppia;
235
+ // polltime() is handed CPU cycles, so how far the VDG advances per
236
+ // cycle depends on how fast the CPU it's attached to runs.
237
+ this.vdgCyclesPerCpuCycle = cpu ? VdgClockMhz / cpu.model.clockMhz : 0;
228
238
  }
229
239
 
230
240
  // USE PAINT from VIDEO
@@ -315,6 +325,7 @@ export class Video6847 {
315
325
  this.pixelsPerBit = this.bitmapPxPerPixel * this.modes[mode][1];
316
326
  let linesPerRow = this.modes[mode][2]; // move to reg9
317
327
  this.bpp = this.modes[mode][3];
328
+ this.updateLineGrid();
318
329
 
319
330
  this.charLinesreg9 = linesPerRow - 1; //2 - scanlines per char
320
331
 
@@ -344,11 +355,7 @@ export class Video6847 {
344
355
  // it doesn't draw the whole frame. It regularly gives control back to the CPU.
345
356
 
346
357
  const vdgcharclock = this.pixelsPerChar / 2; // 4 or 8
347
- // The MC6847 datasheet specifies 3.579545 MHz, but 3.638004 is used here
348
- // as an empirical correction to align VSync/interrupt timing with the Atom's
349
- // 1 MHz CPU clock. TODO: revisit once full integration is testable.
350
- const vdgclock = 3.638004;
351
- this.vdg_cycles += clocks * vdgclock;
358
+ this.vdg_cycles += clocks * this.vdgCyclesPerCpuCycle;
352
359
 
353
360
  const vdgframelines = 262; // 312 PAL (but no pal on standard atom) 262; // NTSC
354
361
  const vdglinetime = 228; // vdg cycles to do a line; not 227.5
@@ -491,9 +498,9 @@ export class Video6847 {
491
498
  {
492
499
  // TODO: Add in the INTEXT modifiers to mode (if necessary)
493
500
  // blit into the fb32 buffer which is painted by VIDEO
494
- if ((mode & MODE_AG) === 0)
495
- // MODE_AG - bit 4; 0x10 is the AG bit
496
- this.blitChar(this.video.fb32, dat, offset, this.pixelsPerChar, css);
501
+ const textMode = (mode & MODE_AG) === 0; // 0x10 is the AG bit
502
+ this.recordLineGrid(textMode);
503
+ if (textMode) this.blitChar(this.video.fb32, dat, offset, this.pixelsPerChar, css);
497
504
  else this.blitPixels(this.video.fb32, dat, offset, css);
498
505
  }
499
506
  }
@@ -619,6 +626,41 @@ export class Video6847 {
619
626
  }
620
627
  }
621
628
 
629
+ /**
630
+ * Precompute this mode's grid descriptors, one per blitter.
631
+ *
632
+ * The two blitters derive their widths differently and cannot be unified:
633
+ * `blitChar` splits the character's texels across eight glyph bits, while
634
+ * `blitPixels` takes the bit width from the mode table — which for text
635
+ * mode holds -1, since it does not blit pixels at all.
636
+ *
637
+ * `pixelsPerBit` is right for graphics at 2bpp as well as 1bpp: the blitter
638
+ * steps its bit groups by `pixelsPerBit / bpp`, but reads the colour with
639
+ * `j & 0xe`, so pairs of groups share one and each pixel is twice as wide.
640
+ */
641
+ updateLineGrid() {
642
+ // Every 6847 blitter writes each pixel row into two framebuffer lines.
643
+ // Text mode's entry is only reachable through blitChar, which is just
644
+ // as well: the mode table holds -1 for it and would not encode.
645
+ this.lineGridText = encodeLineGrid((this.pixelsPerChar * this.bitmapPxPerPixel) / 8, true);
646
+ this.lineGridGraphics = this.pixelsPerBit > 0 ? encodeLineGrid(this.pixelsPerBit, true) : this.lineGridText;
647
+ }
648
+
649
+ /**
650
+ * Note the logical pixel size of the two rows about to be written, so
651
+ * display filters can see the picture as pixels rather than as raster
652
+ * samples (see video-filters/pixel-grid.js). Only the picture records a
653
+ * grid: the border is a solid colour, and letting it write here would
654
+ * leave the row describing the border rather than the picture on it.
655
+ *
656
+ * @param {boolean} textMode whether the character blitter is about to run
657
+ */
658
+ recordLineGrid(textMode) {
659
+ const grid = textMode ? this.lineGridText : this.lineGridGraphics;
660
+ this.video.lineGrid[this.bitmapY] = grid;
661
+ this.video.lineGrid[this.bitmapY + 1] = grid;
662
+ }
663
+
622
664
  blitChar(buf, data, destOffset, numPixels, css) {
623
665
  const scanline = this.scanlineCounter;
624
666
  const chr = data & 0x7f;
package/src/acia.js CHANGED
@@ -265,8 +265,9 @@ export class Acia {
265
265
  }
266
266
  }
267
267
 
268
+ // Byte times are held in CPU cycles because that's what the scheduler counts.
268
269
  secondsToCycles(sec) {
269
- return Math.floor(2 * 1000 * 1000 * sec) | 0;
270
+ return Math.floor(this.cpu.model.cyclesPerSecond * sec) | 0;
270
271
  }
271
272
 
272
273
  numBitsPerByte() {
package/src/canvas.js CHANGED
@@ -2,10 +2,12 @@
2
2
  import webglDebug from "./lib/webgl-debug.js";
3
3
  import { PALCompositeFilter } from "./video-filters/pal-composite.js";
4
4
  import { PassthroughFilter } from "./video-filters/passthrough-filter.js";
5
+ import { XbrFilter } from "./video-filters/xbr-filter.js";
5
6
 
6
7
  const DISPLAY_MODE_FILTERS = {
7
8
  pal: PALCompositeFilter,
8
9
  rgb: PassthroughFilter,
10
+ xbr: XbrFilter,
9
11
  };
10
12
 
11
13
  export function getFilterForMode(mode) {
@@ -13,8 +15,9 @@ export function getFilterForMode(mode) {
13
15
  }
14
16
 
15
17
  export class Canvas {
16
- isWebGl() {
17
- return false;
18
+ /** The 2D canvas draws the framebuffer as-is, which is what this filter is. */
19
+ get filterClass() {
20
+ return PassthroughFilter;
18
21
  }
19
22
 
20
23
  constructor(canvas) {
@@ -27,24 +30,29 @@ export class Canvas {
27
30
  this.backBuffer.height = 625;
28
31
  this.backCtx = this.backBuffer.getContext("2d", { alpha: false });
29
32
  this.imageData = this.backCtx.createImageData(this.backBuffer.width, this.backBuffer.height);
30
- this.canvasWidth = canvas.width;
31
- this.canvasHeight = canvas.height;
33
+ this.canvas = canvas;
32
34
 
33
35
  this.fb32 = new Uint32Array(this.imageData.data.buffer);
34
36
  }
35
- paint(minx, miny, maxx, maxy, _frameCount) {
37
+
38
+ /** Nothing to release: the 2D context owns no objects of ours. */
39
+ dispose() {}
40
+
41
+ paint(minx, miny, maxx, maxy, _frame) {
36
42
  const width = maxx - minx;
37
43
  const height = maxy - miny;
38
44
  this.backCtx.putImageData(this.imageData, 0, 0, minx, miny, width, height);
39
- this.ctx.drawImage(this.backBuffer, minx, miny, width, height, 0, 0, this.canvasWidth, this.canvasHeight);
45
+ // Read the size each time: it can change when the window is resized.
46
+ this.ctx.drawImage(this.backBuffer, minx, miny, width, height, 0, 0, this.canvas.width, this.canvas.height);
40
47
  }
41
48
  }
42
49
 
43
50
  const width = 1024;
44
51
  const height = 1024;
45
52
  export class GlCanvas {
46
- isWebGl() {
47
- return true;
53
+ /** The filter actually built, which may not be the one that was asked for. */
54
+ get filterClass() {
55
+ return this.filter.constructor;
48
56
  }
49
57
 
50
58
  constructor(canvas, filterClass) {
@@ -74,6 +82,10 @@ export class GlCanvas {
74
82
  const program = this.filter.program;
75
83
  checkedGl.useProgram(program);
76
84
 
85
+ // Filters that pick their own samples want the texels they asked for,
86
+ // not a hardware blend of the ones either side.
87
+ const sampling = filterClass.getDisplayConfig().nearestSampling ? checkedGl.NEAREST : checkedGl.LINEAR;
88
+
77
89
  this.fb8 = new Uint8Array(width * height * 4);
78
90
  this.fb32 = new Uint32Array(this.fb8.buffer);
79
91
  this.texture = checkedGl.createTexture();
@@ -81,8 +93,8 @@ export class GlCanvas {
81
93
  checkedGl.pixelStorei(checkedGl.UNPACK_ALIGNMENT, 4);
82
94
  checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_S, checkedGl.CLAMP_TO_EDGE);
83
95
  checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_T, checkedGl.CLAMP_TO_EDGE);
84
- checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER, checkedGl.LINEAR);
85
- checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER, checkedGl.LINEAR);
96
+ checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER, sampling);
97
+ checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER, sampling);
86
98
  checkedGl.texImage2D(
87
99
  checkedGl.TEXTURE_2D,
88
100
  0,
@@ -98,8 +110,8 @@ export class GlCanvas {
98
110
 
99
111
  const vertexPositionAttrLoc = checkedGl.getAttribLocation(program, "pos");
100
112
  checkedGl.enableVertexAttribArray(vertexPositionAttrLoc);
101
- const vertexPositionBuffer = checkedGl.createBuffer();
102
- checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, vertexPositionBuffer);
113
+ this.vertexPositionBuffer = checkedGl.createBuffer();
114
+ checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.vertexPositionBuffer);
103
115
  checkedGl.bufferData(checkedGl.ARRAY_BUFFER, new Float32Array([0, 0, 0, 1, 1, 0, 1, 1]), checkedGl.STATIC_DRAW);
104
116
  checkedGl.vertexAttribPointer(vertexPositionAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
105
117
 
@@ -112,14 +124,42 @@ export class GlCanvas {
112
124
  checkedGl.activeTexture(gl.TEXTURE0);
113
125
  checkedGl.bindTexture(gl.TEXTURE_2D, this.texture);
114
126
 
127
+ this.checkedGl = checkedGl;
128
+ this.viewportWidth = this.viewportHeight = 0;
115
129
  this.uvFloatArray = new Float32Array(8);
116
130
  this.lastExtent = {};
117
131
 
118
132
  console.log("GL Canvas set up");
119
133
  }
120
134
 
121
- paint(minx, miny, maxx, maxy, frameCount) {
135
+ /**
136
+ * Release the GL objects this canvas owns.
137
+ *
138
+ * Switching display mode builds a new canvas over the same element, and a
139
+ * canvas only ever hands out one WebGL context — so the new one inherits
140
+ * the old one's context and the old one's objects stay resident unless
141
+ * they are deleted here. That is a megabytes-per-switch leak: the
142
+ * framebuffer texture alone is 1024x1024 RGBA.
143
+ */
144
+ dispose() {
145
+ const gl = this.checkedGl;
146
+ this.filter.dispose();
147
+ gl.deleteTexture(this.texture);
148
+ gl.deleteBuffer(this.vertexPositionBuffer);
149
+ gl.deleteBuffer(this.uvBuffer);
150
+ this.texture = this.vertexPositionBuffer = this.uvBuffer = null;
151
+ }
152
+
153
+ paint(minx, miny, maxx, maxy, frame) {
122
154
  const gl = this.gl;
155
+ // The drawing buffer can be resized under us — modes that scale to the
156
+ // display do it on every window resize — and the viewport does not
157
+ // follow it.
158
+ if (gl.drawingBufferWidth !== this.viewportWidth || gl.drawingBufferHeight !== this.viewportHeight) {
159
+ this.viewportWidth = gl.drawingBufferWidth;
160
+ this.viewportHeight = gl.drawingBufferHeight;
161
+ gl.viewport(0, 0, this.viewportWidth, this.viewportHeight);
162
+ }
123
163
  // We can't specify a stride for the source, so have to use the full width.
124
164
  gl.texSubImage2D(
125
165
  gl.TEXTURE_2D,
@@ -157,7 +197,17 @@ export class GlCanvas {
157
197
  gl.bufferData(gl.ARRAY_BUFFER, this.uvFloatArray, gl.DYNAMIC_DRAW);
158
198
  }
159
199
 
160
- this.filter.setUniforms({ width, height, frameCount });
200
+ this.filter.setUniforms({
201
+ width,
202
+ height,
203
+ frameCount: frame.frameCount,
204
+ lineGrid: frame.lineGrid,
205
+ // How much of the framebuffer each output pixel covers, which sets
206
+ // how wide an edge-smoothing ramp should be. `extent` holds texel
207
+ // counts; the scaling into texture coordinates above applies only
208
+ // to the local copies that go into the UV buffer.
209
+ texelsPerOutputPixel: (extent.maxx - extent.minx) / gl.drawingBufferWidth,
210
+ });
161
211
 
162
212
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
163
213
  }
@@ -167,11 +217,20 @@ export function bestCanvas(canvas, filterClass) {
167
217
  try {
168
218
  return new GlCanvas(canvas, filterClass);
169
219
  } catch (e) {
170
- console.log("Unable to use OpenGL: " + e);
171
- if (filterClass.requiresGl()) {
172
- const config = filterClass.getDisplayConfig();
173
- console.warn(`${config.name} requires WebGL. Falling back to standard 2D canvas.`);
220
+ // Either WebGL is unavailable or this particular filter declined it.
221
+ console.log(`Unable to use ${filterClass.getDisplayConfig().name} with WebGL: ${e}`);
222
+ }
223
+
224
+ // A canvas that has handed out a WebGL context can never hand out a 2D one,
225
+ // so if the failure came from the filter rather than from WebGL itself, the
226
+ // 2D fallback below would throw and take the emulator with it.
227
+ if (filterClass !== PassthroughFilter) {
228
+ try {
229
+ return new GlCanvas(canvas, PassthroughFilter);
230
+ } catch (e) {
231
+ console.log("Unable to fall back to the passthrough filter: " + e);
174
232
  }
175
233
  }
234
+
176
235
  return new Canvas(canvas);
177
236
  }
package/src/disc-drive.js CHANGED
@@ -33,6 +33,11 @@ export class BaseDiscDrive extends EventTarget {
33
33
  throw new Error("Not implemented: headPosition getter");
34
34
  }
35
35
 
36
+ /** @returns {boolean} */
37
+ get isSideUpper() {
38
+ throw new Error("Not implemented: isSideUpper getter");
39
+ }
40
+
36
41
  /** @returns {boolean} */
37
42
  get indexPulse() {
38
43
  throw new Error("Not implemented: indexPulse getter");
@@ -240,6 +245,10 @@ export class DiscDrive extends BaseDiscDrive {
240
245
  return this._track;
241
246
  }
242
247
 
248
+ get isSideUpper() {
249
+ return this._isSideUpper;
250
+ }
251
+
243
252
  get positionFraction() {
244
253
  return (this._headPosition + this._pulsePosition / 32) / this.trackLength;
245
254
  }
package/src/disc-hfe.js CHANGED
@@ -190,9 +190,8 @@ export function loadHfe(disc, data, onChange) {
190
190
  }
191
191
  }
192
192
 
193
- // Set up write track callback if onChange is provided
194
193
  if (onChange) {
195
- disc.setWriteTrackCallback((_side, _trackNum, _trackObj) => {
194
+ disc.addTrackWriteListener((_side, _trackNum, _trackObj) => {
196
195
  // Generate a complete HFE image from the current disc state
197
196
  const hfeData = toHfe(disc);
198
197
  // Call the onChange handler with the updated HFE data