jsbeeb 1.16.0 → 1.17.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/README.md CHANGED
@@ -240,10 +240,16 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
240
240
  ## URL Parameters
241
241
 
242
242
  - `autoboot` - fakes a shift break
243
- - `disc1=XXX` - loads disc XXX (from the `discs/` directory) into drive 1
244
- - `disc2=XXX` - as above
243
+ - `disc1=XXX` - loads disc XXX (from the `discs/` directory) into drive 0
244
+ - `disc2=XXX` - as above, into drive 1
245
245
  - `disc1=local:YYY` - creates a local disk YYY which will be kept in browser local storage
246
246
  - `disc1=sth:ZZZ` - loads disc ZZZ from the Stairway to Hell archive
247
+ - `drive0Tracks=40` / `drive0Tracks=80` - fixes drive 0's 40/80 track switch, as the switch on the back of a real
248
+ drive did. `drive1Tracks` does the same for drive 1. Left alone, each drive follows whatever disc is loaded into it:
249
+ 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
250
+ 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
251
+ was in 1985. `80` turns all of this off for that drive, loading every image the way jsbeeb did before it could tell
252
+ them apart. [docs/disc-track-layouts.md](docs/disc-track-layouts.md) explains how an image's layout is worked out.
247
253
  - `tape=XXX` - loads tape XXX (from the `tapes/` directory)
248
254
  - `tape=sth:ZZZ` - loads tape ZZZ from the Stairway to Hell archive
249
255
  - `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.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"
@@ -44,6 +44,7 @@
44
44
  "jsdom": "^30.0.1",
45
45
  "lint-staged": "^17.1.1",
46
46
  "npm-run-all2": "^9.0.2",
47
+ "papaparse": "^5.5.4",
47
48
  "pixelmatch": "^7.2.0",
48
49
  "prettier": "^3.9.6",
49
50
  "vite": "^8.1.5",
@@ -104,6 +105,12 @@
104
105
  "mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
105
106
  "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
107
  "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'",
108
+ "mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --out .bbcdiscs-mirror",
109
+ "mirror-bbcdiscs:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
110
+ "mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
111
+ "mirror-bbcdiscs:upload": "npm-run-all mirror-bbcdiscs:upload:*",
112
+ "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'",
113
+ "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
114
  "electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
108
115
  "electron:build": "electron-builder"
109
116
  },
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
+ }
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/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
 
package/src/disc.js CHANGED
@@ -527,6 +527,13 @@ class Side {
527
527
  }
528
528
  }
529
529
 
530
+ /** How an image's tracks are laid out on the surface, and whether that is for the loader to decide. */
531
+ export const DiscLayout = Object.freeze({
532
+ auto: "auto",
533
+ contiguous: "contiguous",
534
+ expanded40: "expanded40",
535
+ });
536
+
530
537
  export class DiscConfig {
531
538
  constructor() {
532
539
  // TODO is this even useful?
@@ -553,6 +560,74 @@ class SsdFormat {
553
560
  static get tracksPerDisc() {
554
561
  return 80;
555
562
  }
563
+
564
+ static get trackSize() {
565
+ return SsdFormat.sectorSize * SsdFormat.sectorsPerTrack;
566
+ }
567
+ }
568
+
569
+ /** @returns {Number} how many logical tracks of `data` hold anything, ignoring a run of trailing zeros */
570
+ function tracksWithData(data, numSides) {
571
+ let lastUsed = data.length - 1;
572
+ while (lastUsed >= 0 && data[lastUsed] === 0) lastUsed--;
573
+ return Math.floor(lastUsed / (SsdFormat.trackSize * numSides)) + 1;
574
+ }
575
+
576
+ // The DFS catalogue is the first two sectors of a side. Sector 1 holds how many files there are and
577
+ // how many sectors the disc has.
578
+ const DfsEntryCountOffset = 0x105;
579
+ const DfsSectorCountOffset = 0x106;
580
+ const DfsEntrySize = 8;
581
+ const DfsMaxEntries = 31;
582
+
583
+ /**
584
+ * Whether an SSD or DSD image was written by a 40 track drive. Only its catalogue can say: images
585
+ * are routinely padded out or cut short, so their size means nothing.
586
+ *
587
+ * @param {Uint8Array} data
588
+ * @param {boolean} isDsd
589
+ * @returns {{is40Track: boolean, reason: string}}
590
+ */
591
+ export function sniffDfsLayout(data, isDsd) {
592
+ const contiguous = (reason) => ({ is40Track: false, reason });
593
+ if (data.length < 2 * SsdFormat.sectorSize) return contiguous("it is smaller than a catalogue");
594
+ const entryBytes = data[DfsEntryCountOffset];
595
+ if (entryBytes % DfsEntrySize !== 0 || entryBytes > DfsMaxEntries * DfsEntrySize)
596
+ return contiguous(`its catalogue claims ${entryBytes} bytes of file entries`);
597
+ const sectors = ((data[DfsSectorCountOffset] & 3) << 8) | data[DfsSectorCountOffset + 1];
598
+ const reason = `its catalogue claims ${sectors} sectors`;
599
+ const fortyTrackSectors = (SsdFormat.tracksPerDisc / 2) * SsdFormat.sectorsPerTrack;
600
+ if (sectors === 0 || sectors > fortyTrackSectors) return contiguous(reason);
601
+ const tracks = tracksWithData(data, isDsd ? 2 : 1);
602
+ if (tracks > IbmDiscFormat.tracksPerDisc / 2) return contiguous(`it holds data as far as track ${tracks - 1}`);
603
+ return { is40Track: true, reason };
604
+ }
605
+
606
+ // One track could match by luck; a disc's worth of them could not.
607
+ const MinDoubleSteppedTracks = 4;
608
+
609
+ /**
610
+ * Whether a surface holds a 40 track format, going by where its sectors say they are: a track
611
+ * written by a 48 tpi head sits at twice the number its own headers claim, with nothing readable
612
+ * on the tracks between. Only a flux image can be asked this, since for any other format the
613
+ * layout is the loader's own doing.
614
+ *
615
+ * @param {Disc} disc
616
+ * @returns {{is40Track: boolean, reason: string}}
617
+ */
618
+ export function sniffSurfaceLayout(disc) {
619
+ let doubleStepped = 0;
620
+ // Both heads move together, so a disc has one pitch and the side that boots can speak for it.
621
+ // Track 0 is where it claims to be at either pitch, so it says nothing.
622
+ for (let trackNum = 1; trackNum < disc.tracksUsed; ++trackNum) {
623
+ const sectors = disc.getTrack(false, trackNum).findSectorIds(() => {});
624
+ if (!sectors.length) continue;
625
+ if (trackNum & 1) return { is40Track: false, reason: `track ${trackNum} holds sectors of its own` };
626
+ if (sectors.some((sector) => sector.trackNumber === trackNum / 2)) doubleStepped++;
627
+ }
628
+ if (doubleStepped < MinDoubleSteppedTracks)
629
+ return { is40Track: false, reason: `only ${doubleStepped} tracks sit at twice their own number` };
630
+ return { is40Track: true, reason: `${doubleStepped} tracks sit at twice the number their sectors claim` };
556
631
  }
557
632
 
558
633
  /**
@@ -576,10 +651,19 @@ export function loadSsd(disc, data, isDsd, onChange) {
576
651
  throw new Error("SSD file is too large");
577
652
  }
578
653
 
654
+ disc.is40Track = disc.config.expandTo80;
655
+ const trackStep = disc.is40Track ? 2 : 1;
656
+ // Tracks twice as far apart are half as many, and an image can run past the last of them as
657
+ // far as the surface has room for.
658
+ const numTracks = Math.min(
659
+ IbmDiscFormat.tracksPerDisc / trackStep,
660
+ Math.max(SsdFormat.tracksPerDisc / trackStep, tracksWithData(data, numSides)),
661
+ );
662
+
579
663
  let offset = 0;
580
- for (let track = 0; track < SsdFormat.tracksPerDisc; ++track) {
664
+ for (let track = 0; track < numTracks; ++track) {
581
665
  for (let side = 0; side < numSides; ++side) {
582
- const trackBuilder = disc.buildTrack(side === 1, track);
666
+ const trackBuilder = disc.buildTrack(side === 1, track * trackStep);
583
667
  // Sync pattern at start of track, as the index pulse starts, aka GAP 5.
584
668
  trackBuilder
585
669
  .appendRepeatFmByte(0xff, IbmDiscFormat.stdGap1FFs)
@@ -631,12 +715,9 @@ export function loadSsd(disc, data, isDsd, onChange) {
631
715
  dataCopy.set(data);
632
716
  disc.addTrackWriteListener(
633
717
  /** @param {Track} trackObj */
634
- (side, trackNum, trackObj) => {
635
- const trackOffset =
636
- SsdFormat.sectorSize * SsdFormat.sectorsPerTrack * (trackNum * numSides + (side ? 1 : 0));
718
+ (side, _trackNum, trackObj) => {
637
719
  for (const sector of trackObj.findSectors())
638
- if (!sectorShortfall(sector, trackNum))
639
- dataCopy.set(sector.sectorData, trackOffset + sector.sectorNumber * SsdFormat.sectorSize);
720
+ if (!sectorShortfall(sector)) dataCopy.set(sector.sectorData, ssdOffsetOf(sector, side, numSides));
640
721
  onChange(dataCopy);
641
722
  },
642
723
  );
@@ -720,17 +801,30 @@ export function loadAdf(disc, data, isDsd) {
720
801
  }
721
802
 
722
803
  /** Why a sector will not fit in an SSD or DSD image, or null if it will. */
723
- function sectorShortfall(sector, trackNum) {
804
+ function sectorShortfall(sector) {
724
805
  if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
725
806
  // A header whose data mark never arrives leaves the sector with nothing to write.
726
807
  if (!sector.sectorData) return "with no data";
727
808
  if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
728
809
  return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
729
810
  if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
730
- if (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
811
+ if (sector.trackNumber >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
731
812
  return null;
732
813
  }
733
814
 
815
+ /**
816
+ * Where a sector belongs in an SSD or DSD image, which is the track its own header claims rather
817
+ * than the one it sits on.
818
+ *
819
+ * @param {Sector} sector
820
+ * @param {boolean} isSideUpper
821
+ * @param {Number} numSides
822
+ */
823
+ function ssdOffsetOf(sector, isSideUpper, numSides) {
824
+ const track = sector.trackNumber * numSides + (isSideUpper ? 1 : 0);
825
+ return track * SsdFormat.trackSize + sector.sectorNumber * SsdFormat.sectorSize;
826
+ }
827
+
734
828
  /**
735
829
  * SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
736
830
  * have held is lost. Copy protection usually shows up as one of these.
@@ -743,7 +837,7 @@ export function ssdOrDsdShortfalls(disc) {
743
837
  for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
744
838
  for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
745
839
  for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
746
- const shortfall = sectorShortfall(sector, trackNum);
840
+ const shortfall = sectorShortfall(sector);
747
841
  if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
748
842
  }
749
843
  }
@@ -770,22 +864,19 @@ export function toSsdOrDsd(disc, { force = false } = {}) {
770
864
  );
771
865
  }
772
866
  const numSides = disc.isDoubleSided ? 2 : 1;
773
- const result = new Uint8Array(
774
- numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
775
- );
776
- let offset = 0;
867
+ const result = new Uint8Array(numSides * SsdFormat.tracksPerDisc * SsdFormat.trackSize);
868
+ let numTracks = 0;
777
869
  for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
778
870
  for (let side = 0; side < numSides; ++side) {
779
871
  const trackObj = disc.getTrack(side === 1, trackNum);
780
872
  for (const sector of trackObj.findSectors()) {
781
- if (sectorShortfall(sector, trackNum)) continue;
782
- const sectorOffset = offset + sector.sectorNumber * SsdFormat.sectorSize;
783
- for (let x = 0; x < SsdFormat.sectorSize; ++x) result[sectorOffset + x] = sector.sectorData[x];
873
+ if (sectorShortfall(sector)) continue;
874
+ result.set(sector.sectorData, ssdOffsetOf(sector, side === 1, numSides));
875
+ numTracks = Math.max(numTracks, sector.trackNumber + 1);
784
876
  }
785
- offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
786
877
  }
787
878
  }
788
- return result.slice(0, offset);
879
+ return result.slice(0, numTracks * numSides * SsdFormat.trackSize);
789
880
  }
790
881
 
791
882
  export class Disc {
@@ -810,6 +901,8 @@ export class Disc {
810
901
  this.dirtyTrack = -1;
811
902
  this.tracksUsed = 0;
812
903
  this.isDoubleSided = false;
904
+ // Whether the surface holds a 48 tpi layout, which a drive reads by double stepping.
905
+ this.is40Track = false;
813
906
 
814
907
  this._trackWriteListeners = new Set();
815
908
  this.isWriteable = isWriteable;
@@ -944,10 +1037,11 @@ export class Disc {
944
1037
  // console.log(`wrote to ${track}:${position * 32}`);
945
1038
  }
946
1039
 
1040
+ /** @returns {?{isSideUpper: boolean, trackNum: Number}} the track written, if there was one */
947
1041
  flushWrites() {
948
1042
  if (!this.isDirty) {
949
1043
  if (this.dirtySide !== -1 || this.dirtyTrack !== -1) throw new Error("Bad state in disc dirty tracking");
950
- return;
1044
+ return null;
951
1045
  }
952
1046
 
953
1047
  const dirtySide = this.dirtySide;
@@ -958,6 +1052,24 @@ export class Disc {
958
1052
  const trackObj = this.getTrack(dirtySide, dirtyTrack);
959
1053
  this.setTrackUsed(dirtySide, dirtyTrack);
960
1054
  for (const listener of this._trackWriteListeners) listener(dirtySide, dirtyTrack, trackObj);
1055
+ return { isSideUpper: dirtySide, trackNum: dirtyTrack };
1056
+ }
1057
+
1058
+ /**
1059
+ * Leave a track with no flux on it at all, as an erase head does.
1060
+ *
1061
+ * @param {boolean} isSideUpper
1062
+ * @param {Number} trackNum
1063
+ */
1064
+ eraseTrack(isSideUpper, trackNum) {
1065
+ const trackObj = this.getTrack(isSideUpper, trackNum);
1066
+ trackObj.pulses2Us.fill(0);
1067
+ trackObj.length = IbmDiscFormat.bytesPerTrack;
1068
+ const dirtyKey = trackNum | (isSideUpper ? 0x100 : 0);
1069
+ this._snapshotDirtyTracks.add(dirtyKey);
1070
+ this._everDirtyTracks.add(dirtyKey);
1071
+ this.setTrackUsed(isSideUpper, trackNum);
1072
+ for (const listener of this._trackWriteListeners) listener(isSideUpper, trackNum, trackObj);
961
1073
  }
962
1074
 
963
1075
  /**