jsbeeb 1.16.0 → 1.17.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/README.md CHANGED
@@ -13,6 +13,7 @@ different peripherals.
13
13
  - [Keyboard Mappings](#keyboard-mappings)
14
14
  - [Remapping Keys](#remapping-keys)
15
15
  - [Emulator Shortcuts](#emulator-shortcuts)
16
+ - [Printer Output](#printer-output)
16
17
  - [Save State and Rewind](#save-state-and-rewind)
17
18
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
18
19
  - [Running as a Desktop Application](#running-as-a-desktop-application)
@@ -103,8 +104,15 @@ The definitive lists are `keyCodes` (host) and `BBC` (BBC micro) in [`src/utils.
103
104
  | `Ctrl+Home` | Stop and enter debugger |
104
105
  | `Ctrl+Insert` | Toggle turbo (fast-as-possible) |
105
106
  | `Ctrl+End` | Pause emulation |
107
+ | `Ctrl+B` | Open printer output window |
106
108
  | `Alt+PageDown` | Open rewind scrubber |
107
109
 
110
+ ### Printer Output
111
+
112
+ Anything the machine prints is captured whether or not the printer window is open, so programs that print (with `VDU 2`, `*FX5,1` and the like) run rather than waiting for a printer that is not there.
113
+
114
+ Press `Ctrl+B` to open a window showing what has been printed so far; it then keeps up with the output as it arrives. Only the most recent output is kept, roughly a dozen pages' worth, so a program printing forever cannot fill memory.
115
+
108
116
  ### Save State and Rewind
109
117
 
110
118
  Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S` / `Ctrl+O` in the Electron app).
@@ -240,10 +248,16 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
240
248
  ## URL Parameters
241
249
 
242
250
  - `autoboot` - fakes a shift break
243
- - `disc1=XXX` - loads disc XXX (from the `discs/` directory) into drive 1
244
- - `disc2=XXX` - as above
251
+ - `disc1=XXX` - loads disc XXX (from the `discs/` directory) into drive 0
252
+ - `disc2=XXX` - as above, into drive 1
245
253
  - `disc1=local:YYY` - creates a local disk YYY which will be kept in browser local storage
246
254
  - `disc1=sth:ZZZ` - loads disc ZZZ from the Stairway to Hell archive
255
+ - `drive0Tracks=40` / `drive0Tracks=80` - fixes drive 0's 40/80 track switch, as the switch on the back of a real
256
+ drive did. `drive1Tracks` does the same for drive 1. Left alone, each drive follows whatever disc is loaded into it:
257
+ a 40 track image is laid out the way a 40 track drive wrote it, on every other track of the surface, and the drive
258
+ double steps to read it. `40` reads an 80 track disc through a double stepping head, which is as much of a mess as it
259
+ was in 1985. `80` turns all of this off for that drive, loading every image the way jsbeeb did before it could tell
260
+ them apart. [docs/disc-track-layouts.md](docs/disc-track-layouts.md) explains how an image's layout is worked out.
247
261
  - `tape=XXX` - loads tape XXX (from the `tapes/` directory)
248
262
  - `tape=sth:ZZZ` - loads tape ZZZ from the Stairway to Hell archive
249
263
  - `KEY.X=Y` - makes host key `X` press BBC key `Y`, e.g. `KEY.ENTER=COPY`. See
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.16.0",
10
+ "version": "1.17.1",
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"
@@ -30,6 +30,7 @@
30
30
  "argparse": "^3.0.0",
31
31
  "bootstrap": "^5.3.8",
32
32
  "bootswatch": "^5.3.8",
33
+ "pako": "^3.0.1",
33
34
  "sharp": "^0.35.3",
34
35
  "smoothie": "^1.36.1"
35
36
  },
@@ -44,6 +45,7 @@
44
45
  "jsdom": "^30.0.1",
45
46
  "lint-staged": "^17.1.1",
46
47
  "npm-run-all2": "^9.0.2",
48
+ "papaparse": "^5.5.4",
47
49
  "pixelmatch": "^7.2.0",
48
50
  "prettier": "^3.9.6",
49
51
  "vite": "^8.1.5",
@@ -104,6 +106,12 @@
104
106
  "mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
105
107
  "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
108
  "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'",
109
+ "mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --out .bbcdiscs-mirror",
110
+ "mirror-bbcdiscs:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
111
+ "mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
112
+ "mirror-bbcdiscs:upload": "npm-run-all mirror-bbcdiscs:upload:*",
113
+ "mirror-bbcdiscs:upload:blobs": "aws s3 sync .bbcdiscs-mirror/hfe s3://bbc.xania.org/archive/bbcdiscs/hfe/ --no-progress --exclude 'manifest.json' --delete --content-encoding br --cache-control 'public, max-age=31536000, immutable'",
114
+ "mirror-bbcdiscs:upload:index": "aws s3 cp .bbcdiscs-mirror/hfe/manifest.json s3://bbc.xania.org/archive/bbcdiscs/hfe/manifest.json --cache-control 'public, max-age=300' && aws s3 cp .bbcdiscs-mirror/manifest.json s3://bbc.xania.org/archive/bbcdiscs/manifest.json --cache-control 'public, max-age=300'",
107
115
  "electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
108
116
  "electron:build": "electron-builder"
109
117
  },
package/src/6502.js CHANGED
@@ -1359,7 +1359,8 @@ export class Cpu6502 extends Base6502 {
1359
1359
  // Override in subclasses for different peripheral sets.
1360
1360
  // Only called on hard reset.
1361
1361
  resetPeripherals() {
1362
- if (this.config.printerPort) this.uservia.ca2changecallback = this.config.printerPort.outputStrobe;
1362
+ if (this.config.printerPort)
1363
+ this.uservia.ca2changecallback = (level, output) => this.config.printerPort.outputStrobe(level, output);
1363
1364
 
1364
1365
  this.sysvia.reset();
1365
1366
  this.uservia.reset();
package/src/app/app.js CHANGED
@@ -172,6 +172,10 @@ const template = [
172
172
  label: "Browse STH Disc Archive...",
173
173
  click: showModal("sth", { sthType: "discs" }),
174
174
  },
175
+ {
176
+ label: "Browse HFE Disc Archive...",
177
+ click: showModal("hfe"),
178
+ },
175
179
  {
176
180
  label: "Browse Example Discs...",
177
181
  click: showModal("discs"),
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+
3
+ const mirrorBase = "https://bbc.xania.org/archive/bbcdiscs";
4
+
5
+ // Numeric so a "Disc 2" would sort before a "Disc 10" rather than after it,
6
+ // and case-insensitive so a lower-cased title stays with its neighbours.
7
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
8
+
9
+ /**
10
+ * Order the picker by what someone is looking for, which is the disc's name.
11
+ * The catalogue arrives grouped by publisher, so it has to be sorted here; the
12
+ * remaining keys only settle ties, keeping a title's variants together and in
13
+ * a stable order rather than the one the catalogue happens to list them in.
14
+ */
15
+ export const byTitle = (a, b) =>
16
+ collator.compare(a.title || a.path, b.title || b.path) ||
17
+ collator.compare(a.publisher || "", b.publisher || "") ||
18
+ collator.compare(a.disc || "", b.disc || "") ||
19
+ collator.compare(a.variant || "", b.variant || "");
20
+
21
+ /**
22
+ * How a disc reads in the picker. Several fingerprinted variants of one title
23
+ * sit next to each other, so the title alone doesn't identify a disc.
24
+ *
25
+ * @param {object} file manifest entry
26
+ * @returns {{title: string, publisher: string, detail: string}}
27
+ */
28
+ export function describe(file) {
29
+ const detail = [file.disc, file.tracks?.join(", "), file.variant && `v${file.variant}`].filter(Boolean);
30
+ return {
31
+ title: file.title || file.path,
32
+ publisher: file.publisher ?? "",
33
+ detail: detail.join(" · "),
34
+ };
35
+ }
36
+
37
+ export class BbcDiscArchive {
38
+ /** @param {string} [baseUrl] where the mirror lives, to point at a test prefix */
39
+ constructor(onStart, onCat, onError, baseUrl = mirrorBase) {
40
+ this._baseUrl = `${baseUrl}/hfe/`;
41
+ this._catalogue = [];
42
+ this._loaded = false;
43
+ this._onStart = onStart;
44
+ this._onCat = onCat;
45
+ this._onError = onError;
46
+ }
47
+
48
+ async populate() {
49
+ this._onStart();
50
+ // Tracked separately from the catalogue: an archive can legitimately be
51
+ // empty, and an empty array would mean "fetch it again" every time.
52
+ if (!this._loaded) {
53
+ try {
54
+ const response = await fetch(`${this._baseUrl}manifest.json`);
55
+ if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
56
+ const data = await response.json();
57
+ if (!Array.isArray(data?.files)) throw new Error("Invalid manifest: missing files array");
58
+ this._catalogue = [...data.files].sort(byTitle);
59
+ this._loaded = true;
60
+ } catch (error) {
61
+ console.error("Failed to fetch HFE archive catalogue:", error);
62
+ if (this._onError) this._onError();
63
+ return;
64
+ }
65
+ }
66
+ if (this._onCat) this._onCat(this._catalogue);
67
+ }
68
+
69
+ /**
70
+ * Nothing to unzip, unlike sth.js: blobs are stored compressed and served
71
+ * with `Content-Encoding: br`, which the browser has undone by the time
72
+ * this resolves.
73
+ *
74
+ * @param {string} path a manifest entry's `path`
75
+ * @returns {Promise<Uint8Array>} the HFE image
76
+ */
77
+ async fetch(path) {
78
+ const url = this._baseUrl + encodeURIComponent(path);
79
+ console.log("Loading HFE from " + url);
80
+ const response = await fetch(url);
81
+ if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
82
+ return new Uint8Array(await response.arrayBuffer());
83
+ }
84
+ }
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- import { decompress } from "./utils.js";
2
+ import { inflate } from "./utils.js";
3
3
 
4
4
  // B-em snapshot format parser (versions 1 and 3).
5
5
  // v1 (BEMSNAP1): Fixed-size 327,885 byte packed struct. Reference: beebjit state.c
@@ -334,7 +334,7 @@ async function parseBemTube(sections, tubeName) {
334
334
 
335
335
  // Those names come from a config file, so size is the real check. It cannot be exact: the
336
336
  // trailing ROM's size comes from that same config, and b-em records it as zero if unset.
337
- const parasite = await decompress(sections["P"].data, "deflate");
337
+ const parasite = inflate(sections["P"].data);
338
338
  const romBytes = parasite.length - BemTubeRegisterBytes - BemTubeRamSize;
339
339
  if (romBytes < 0 || romBytes > BemTubeRamSize) {
340
340
  throw new Error(
@@ -449,9 +449,8 @@ async function parseBemV3(buffer) {
449
449
  let roms = null;
450
450
  const memSection = sections["M"];
451
451
  if (memSection) {
452
- // Memory is zlib-compressed; decompression is async.
453
452
  // Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
454
- const memData = await decompress(memSection.data, "deflate");
453
+ const memData = inflate(memSection.data);
455
454
  cpuState.fe30 = memData[0];
456
455
  cpuState.fe34 = memData[1];
457
456
  const ramStart = 2;
package/src/canvas.js CHANGED
@@ -213,11 +213,18 @@ export class GlCanvas {
213
213
  }
214
214
  }
215
215
 
216
+ function fellBackBecause(canvas, reason) {
217
+ canvas.fallbackReason = reason;
218
+ return canvas;
219
+ }
220
+
216
221
  export function bestCanvas(canvas, filterClass) {
222
+ let reason;
217
223
  try {
218
224
  return new GlCanvas(canvas, filterClass);
219
225
  } catch (e) {
220
226
  // Either WebGL is unavailable or this particular filter declined it.
227
+ reason = e?.message ?? e;
221
228
  console.log(`Unable to use ${filterClass.getDisplayConfig().name} with WebGL: ${e}`);
222
229
  }
223
230
 
@@ -226,11 +233,11 @@ export function bestCanvas(canvas, filterClass) {
226
233
  // 2D fallback below would throw and take the emulator with it.
227
234
  if (filterClass !== PassthroughFilter) {
228
235
  try {
229
- return new GlCanvas(canvas, PassthroughFilter);
236
+ return fellBackBecause(new GlCanvas(canvas, PassthroughFilter), reason);
230
237
  } catch (e) {
231
238
  console.log("Unable to fall back to the passthrough filter: " + e);
232
239
  }
233
240
  }
234
241
 
235
- return new Canvas(canvas);
242
+ return fellBackBecause(new Canvas(canvas), reason);
236
243
  }
package/src/cmos.js CHANGED
@@ -33,6 +33,44 @@ function fromBcd(value) {
33
33
 
34
34
  export { defaultCmos };
35
35
 
36
+ /**
37
+ * CMOS persistence backed by a browser storage object, which can be unavailable, full or holding
38
+ * something other than what was last saved.
39
+ *
40
+ * @param {function(): Storage} getStorage typically `() => window.localStorage`. Reading that
41
+ * property is itself what throws when a page is refused storage, so it happens per call, inside
42
+ * the try.
43
+ * @param {function(*)} onSaveFailure called with the error the first time a save fails
44
+ */
45
+ export function localStoragePersistence(getStorage, onSaveFailure) {
46
+ let saveFailureReported = false;
47
+ return {
48
+ load() {
49
+ try {
50
+ const stored = getStorage().cmosRam;
51
+ if (!stored) return null;
52
+ const parsed = JSON.parse(stored);
53
+ if (!Array.isArray(parsed) || parsed.length !== defaultCmos.length)
54
+ throw new Error(`the stored settings are not ${defaultCmos.length} bytes`);
55
+ return parsed;
56
+ } catch (error) {
57
+ console.log(`Unable to read the stored CMOS settings: ${error?.message ?? error}`);
58
+ return null;
59
+ }
60
+ },
61
+ save(data) {
62
+ try {
63
+ getStorage().cmosRam = JSON.stringify(data);
64
+ } catch (error) {
65
+ console.log(`Unable to store the CMOS settings: ${error?.message ?? error}`);
66
+ if (saveFailureReported) return;
67
+ saveFailureReported = true;
68
+ onSaveFailure(error);
69
+ }
70
+ },
71
+ };
72
+ }
73
+
36
74
  export class Cmos {
37
75
  constructor(persistence, cmosOverride, econet) {
38
76
  this.store = persistence ? persistence.load() : null;
package/src/disc-drive.js CHANGED
@@ -38,6 +38,16 @@ export class BaseDiscDrive extends EventTarget {
38
38
  throw new Error("Not implemented: isSideUpper getter");
39
39
  }
40
40
 
41
+ /** @returns {Number} */
42
+ get tracksPerStep() {
43
+ throw new Error("Not implemented: tracksPerStep getter");
44
+ }
45
+
46
+ /** @param {Number} _tracksPerStep */
47
+ set tracksPerStep(_tracksPerStep) {
48
+ throw new Error("Not implemented: tracksPerStep setter");
49
+ }
50
+
41
51
  /** @returns {boolean} */
42
52
  get indexPulse() {
43
53
  throw new Error("Not implemented: indexPulse getter");
@@ -160,8 +170,10 @@ export class DiscDrive extends BaseDiscDrive {
160
170
  this._scheduler = scheduler;
161
171
  /** @type {Disc|undefined} */
162
172
  this._disc = undefined;
163
- this._is40Track = false;
164
- // Physically always 80 tracks even if we're in 40 track mode. 40 track mode essentially double steps.
173
+ // Two for a drive whose 40/80 switch is set to 40, which reaches a 48 tpi format by
174
+ // stepping twice for each track the controller counts.
175
+ this._tracksPerStep = 1;
176
+ // Where the head is over the 96 tpi surface, whatever the controller believes.
165
177
  this._track = 0;
166
178
  this._isSideUpper = false;
167
179
  // In units where 3125 is a normal track length.
@@ -305,6 +317,22 @@ export class DiscDrive extends BaseDiscDrive {
305
317
  this._disc = disc;
306
318
  }
307
319
 
320
+ /** @returns {Number} how many of the surface's tracks the head crosses for one of the format's */
321
+ get tracksPerStep() {
322
+ return this._tracksPerStep;
323
+ }
324
+
325
+ set tracksPerStep(tracksPerStep) {
326
+ if (tracksPerStep !== 1 && tracksPerStep !== 2)
327
+ throw new Error(`Drives step over one or two tracks at a time, not ${tracksPerStep}`);
328
+ this._tracksPerStep = tracksPerStep;
329
+ }
330
+
331
+ /** @returns {Number} the track the controller believes the head is on */
332
+ get logicalTrack() {
333
+ return (this._track / this._tracksPerStep) | 0;
334
+ }
335
+
308
336
  get indexPulse() {
309
337
  // With no disc loaded the drive asserts the index all the time.
310
338
  if (!this.disc) return true;
@@ -339,22 +367,22 @@ export class DiscDrive extends BaseDiscDrive {
339
367
  * @param {Number} delta track step delta, either 1 or -1
340
368
  */
341
369
  seekOneTrack(delta) {
342
- if (this._is40Track) delta *= 2;
343
- this._selectTrack(this._track + delta);
370
+ this._selectTrack(this._track + delta * this._tracksPerStep);
344
371
  }
345
372
 
346
373
  /**
347
374
  * Notify that an overall seek is happening to a particular track. Purely informational.
348
375
  */
349
376
  notifySeek(newTrack) {
350
- this.notifySeekAmount(newTrack - this._track);
377
+ this.notifySeekAmount(newTrack - this.logicalTrack);
351
378
  }
352
379
 
353
380
  /**
354
- * Notify that an overall seek is happening by some delta smount. Purely informational.
381
+ * Notify that an overall seek is happening by some delta amount. Purely informational.
355
382
  */
356
383
  notifySeekAmount(delta) {
357
- this.dispatchEvent(new StepEvent(delta));
384
+ // The step drives the seek noise, so it counts the tracks the head crosses.
385
+ this.dispatchEvent(new StepEvent(delta * this._tracksPerStep));
358
386
  }
359
387
 
360
388
  /**
@@ -362,11 +390,12 @@ export class DiscDrive extends BaseDiscDrive {
362
390
  */
363
391
  _selectTrack(track) {
364
392
  this._checkTrackNeedsWrite();
393
+ const lastTrack = IbmDiscFormat.tracksPerDisc - this._tracksPerStep;
365
394
  if (track < 0) {
366
395
  track = 0;
367
396
  console.log("Clang! disc head stopped at track 0");
368
- } else if (track >= IbmDiscFormat.tracksPerDisc) {
369
- track = IbmDiscFormat.tracksPerDisc - 1;
397
+ } else if (track > lastTrack) {
398
+ track = lastTrack;
370
399
  console.log("Clang! disc head stopper at track max");
371
400
  }
372
401
  const fraction = this.positionFraction;
@@ -375,7 +404,11 @@ export class DiscDrive extends BaseDiscDrive {
375
404
  }
376
405
 
377
406
  _checkTrackNeedsWrite() {
378
- if (this.disc) this.disc.flushWrites();
407
+ if (!this.disc) return;
408
+ const written = this.disc.flushWrites();
409
+ // A 48 tpi head writes across most of its band but not as far as the neighbouring 96 tpi
410
+ // track, which is left in the guard band with nothing readable on it.
411
+ if (written && this._tracksPerStep === 2) this.disc.eraseTrack(written.isSideUpper, written.trackNum ^ 1);
379
412
  }
380
413
 
381
414
  snapshotState() {
@@ -386,7 +419,7 @@ export class DiscDrive extends BaseDiscDrive {
386
419
  pulsePosition: this._pulsePosition,
387
420
  in32usMode: this._in32usMode,
388
421
  spinning: this._spinning,
389
- is40Track: this._is40Track,
422
+ is40Track: this._tracksPerStep === 2,
390
423
  timerTaskOffset: this._timer.scheduled() ? this._timer.expireEpoch - this._scheduler.epoch : null,
391
424
  disc: this._disc ? this._disc.snapshotState() : null,
392
425
  };
@@ -398,7 +431,7 @@ export class DiscDrive extends BaseDiscDrive {
398
431
  this._headPosition = state.headPosition;
399
432
  this._pulsePosition = state.pulsePosition;
400
433
  this._in32usMode = state.in32usMode;
401
- this._is40Track = state.is40Track;
434
+ this._tracksPerStep = state.is40Track ? 2 : 1;
402
435
 
403
436
  // Restore spinning state and timer
404
437
  this._timer.cancel();
package/src/disc-hfe.js CHANGED
@@ -12,6 +12,7 @@ const HfeV3OpcodeSetIndex = 0xf1;
12
12
  const HfeV3OpcodeSetBitrate = 0xf2;
13
13
  const HfeV3OpcodeSkipBits = 0xf3;
14
14
  const HfeV3OpcodeRand = 0xf4;
15
+ const HfeTrackCountOffset = 9;
15
16
  const HfeBlockSideSize = 256;
16
17
  const HfeBlockSize = HfeBlockSideSize * 2;
17
18
  const HfeShugartDdFloppyMode = 7;
@@ -52,6 +53,21 @@ function hfeGetTrackOffsetAndLength(metadata, track) {
52
53
  return { offset, length };
53
54
  }
54
55
 
56
+ /**
57
+ * Whether an HFE image holds a 40 track format. A capture with no more tracks than half a surface
58
+ * cannot be one of an 80 track disc, whatever drive read it.
59
+ *
60
+ * @param {Uint8Array} data
61
+ * @returns {{is40Track: boolean, reason: string}}
62
+ */
63
+ export function sniffHfeLayout(data) {
64
+ const numTracks = data.length > HfeTrackCountOffset ? data[HfeTrackCountOffset] : 0;
65
+ return {
66
+ is40Track: numTracks > 0 && numTracks * 2 <= IbmDiscFormat.tracksPerDisc,
67
+ reason: `its header declares ${numTracks} tracks`,
68
+ };
69
+ }
70
+
55
71
  /**
56
72
  * Load a disc image in HFE format (v1 or v3)
57
73
  * @param {import("./disc.js").Disc} disc - The disc object to load into
@@ -84,11 +100,12 @@ export function loadHfe(disc, data, onChange) {
84
100
  const numSides = data[10];
85
101
  if (numSides < 1 || numSides > 2) throw new Error(`Invalid number of sides: ${numSides}`);
86
102
 
87
- const numTracks = data[9];
103
+ const numTracks = data[HfeTrackCountOffset];
88
104
  if (numTracks > IbmDiscFormat.tracksPerDisc) throw new Error(`Too many tracks: ${numTracks}`);
89
105
  let expandShift = 0;
90
106
  if (disc.config.expandTo80 && numTracks * 2 <= IbmDiscFormat.tracksPerDisc) {
91
107
  expandShift = 1;
108
+ disc.is40Track = true;
92
109
  console.log("Expanding 40 tracks to 80");
93
110
  }
94
111
 
@@ -196,7 +213,7 @@ export function loadHfe(disc, data, onChange) {
196
213
  const hfeData = toHfe(disc);
197
214
  // Call the onChange handler with the updated HFE data
198
215
  onChange(hfeData);
199
- });
216
+ }, true);
200
217
  }
201
218
 
202
219
  return disc;