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/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,14 +715,12 @@ 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
  },
723
+ true,
642
724
  );
643
725
  }
644
726
  return disc;
@@ -720,17 +802,30 @@ export function loadAdf(disc, data, isDsd) {
720
802
  }
721
803
 
722
804
  /** Why a sector will not fit in an SSD or DSD image, or null if it will. */
723
- function sectorShortfall(sector, trackNum) {
805
+ function sectorShortfall(sector) {
724
806
  if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
725
807
  // A header whose data mark never arrives leaves the sector with nothing to write.
726
808
  if (!sector.sectorData) return "with no data";
727
809
  if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
728
810
  return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
729
811
  if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
730
- if (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
812
+ if (sector.trackNumber >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
731
813
  return null;
732
814
  }
733
815
 
816
+ /**
817
+ * Where a sector belongs in an SSD or DSD image, which is the track its own header claims rather
818
+ * than the one it sits on.
819
+ *
820
+ * @param {Sector} sector
821
+ * @param {boolean} isSideUpper
822
+ * @param {Number} numSides
823
+ */
824
+ function ssdOffsetOf(sector, isSideUpper, numSides) {
825
+ const track = sector.trackNumber * numSides + (isSideUpper ? 1 : 0);
826
+ return track * SsdFormat.trackSize + sector.sectorNumber * SsdFormat.sectorSize;
827
+ }
828
+
734
829
  /**
735
830
  * SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
736
831
  * have held is lost. Copy protection usually shows up as one of these.
@@ -743,7 +838,7 @@ export function ssdOrDsdShortfalls(disc) {
743
838
  for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
744
839
  for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
745
840
  for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
746
- const shortfall = sectorShortfall(sector, trackNum);
841
+ const shortfall = sectorShortfall(sector);
747
842
  if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
748
843
  }
749
844
  }
@@ -770,22 +865,19 @@ export function toSsdOrDsd(disc, { force = false } = {}) {
770
865
  );
771
866
  }
772
867
  const numSides = disc.isDoubleSided ? 2 : 1;
773
- const result = new Uint8Array(
774
- numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
775
- );
776
- let offset = 0;
868
+ const result = new Uint8Array(numSides * SsdFormat.tracksPerDisc * SsdFormat.trackSize);
869
+ let numTracks = 0;
777
870
  for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
778
871
  for (let side = 0; side < numSides; ++side) {
779
872
  const trackObj = disc.getTrack(side === 1, trackNum);
780
873
  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];
874
+ if (sectorShortfall(sector)) continue;
875
+ result.set(sector.sectorData, ssdOffsetOf(sector, side === 1, numSides));
876
+ numTracks = Math.max(numTracks, sector.trackNumber + 1);
784
877
  }
785
- offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
786
878
  }
787
879
  }
788
- return result.slice(0, offset);
880
+ return result.slice(0, numTracks * numSides * SsdFormat.trackSize);
789
881
  }
790
882
 
791
883
  export class Disc {
@@ -810,8 +902,11 @@ export class Disc {
810
902
  this.dirtyTrack = -1;
811
903
  this.tracksUsed = 0;
812
904
  this.isDoubleSided = false;
905
+ // Whether the surface holds a 48 tpi layout, which a drive reads by double stepping.
906
+ this.is40Track = false;
813
907
 
814
908
  this._trackWriteListeners = new Set();
909
+ this._savingListeners = new Set();
815
910
  this.isWriteable = isWriteable;
816
911
 
817
912
  // Track which tracks have been written since the last snapshot.
@@ -838,14 +933,32 @@ export class Disc {
838
933
  this.initSurface(0);
839
934
  }
840
935
 
841
- /** @param {function(boolean, Number, Track): void} listener called once per flushed track */
842
- addTrackWriteListener(listener) {
936
+ /**
937
+ * @param {function(boolean, Number, Track): void} listener called once per flushed track
938
+ * @param {boolean} [savesChanges] whether the listener puts the image somewhere it is read back from
939
+ */
940
+ addTrackWriteListener(listener, savesChanges = false) {
843
941
  this._trackWriteListeners.add(listener);
942
+ if (savesChanges) this._savingListeners.add(listener);
943
+ }
944
+
945
+ get savesChanges() {
946
+ return this._savingListeners.size > 0;
844
947
  }
845
948
 
846
949
  /** @param {function(boolean, Number, Track): void} listener */
847
950
  removeTrackWriteListener(listener) {
848
951
  this._trackWriteListeners.delete(listener);
952
+ this._savingListeners.delete(listener);
953
+ }
954
+
955
+ /** @param {function(): void} listener called once, when a track is first written to this disc */
956
+ notifyOnFirstTrackWrite(listener) {
957
+ const onFirstWrite = () => {
958
+ this.removeTrackWriteListener(onFirstWrite);
959
+ listener();
960
+ };
961
+ this.addTrackWriteListener(onFirstWrite);
849
962
  }
850
963
 
851
964
  /**
@@ -944,10 +1057,11 @@ export class Disc {
944
1057
  // console.log(`wrote to ${track}:${position * 32}`);
945
1058
  }
946
1059
 
1060
+ /** @returns {?{isSideUpper: boolean, trackNum: Number}} the track written, if there was one */
947
1061
  flushWrites() {
948
1062
  if (!this.isDirty) {
949
1063
  if (this.dirtySide !== -1 || this.dirtyTrack !== -1) throw new Error("Bad state in disc dirty tracking");
950
- return;
1064
+ return null;
951
1065
  }
952
1066
 
953
1067
  const dirtySide = this.dirtySide;
@@ -958,6 +1072,24 @@ export class Disc {
958
1072
  const trackObj = this.getTrack(dirtySide, dirtyTrack);
959
1073
  this.setTrackUsed(dirtySide, dirtyTrack);
960
1074
  for (const listener of this._trackWriteListeners) listener(dirtySide, dirtyTrack, trackObj);
1075
+ return { isSideUpper: dirtySide, trackNum: dirtyTrack };
1076
+ }
1077
+
1078
+ /**
1079
+ * Leave a track with no flux on it at all, as an erase head does.
1080
+ *
1081
+ * @param {boolean} isSideUpper
1082
+ * @param {Number} trackNum
1083
+ */
1084
+ eraseTrack(isSideUpper, trackNum) {
1085
+ const trackObj = this.getTrack(isSideUpper, trackNum);
1086
+ trackObj.pulses2Us.fill(0);
1087
+ trackObj.length = IbmDiscFormat.bytesPerTrack;
1088
+ const dirtyKey = trackNum | (isSideUpper ? 0x100 : 0);
1089
+ this._snapshotDirtyTracks.add(dirtyKey);
1090
+ this._everDirtyTracks.add(dirtyKey);
1091
+ this.setTrackUsed(isSideUpper, trackNum);
1092
+ for (const listener of this._trackWriteListeners) listener(isSideUpper, trackNum, trackObj);
961
1093
  }
962
1094
 
963
1095
  /**
package/src/fdc.js CHANGED
@@ -1,6 +1,15 @@
1
1
  // Floppy disc assorted utils.
2
- import { Disc, DiscConfig, loadAdf, loadSsd, toSsdOrDsd } from "./disc.js";
3
- import { loadHfe, toHfe } from "./disc-hfe.js";
2
+ import {
3
+ Disc,
4
+ DiscConfig,
5
+ DiscLayout,
6
+ loadAdf,
7
+ loadSsd,
8
+ sniffDfsLayout,
9
+ sniffSurfaceLayout,
10
+ toSsdOrDsd,
11
+ } from "./disc.js";
12
+ import { loadHfe, sniffHfeLayout, toHfe } from "./disc-hfe.js";
4
13
  import * as utils from "./utils.js";
5
14
 
6
15
  export function load(name) {
@@ -19,15 +28,29 @@ export class DiscType {
19
28
  * @param {function(Disc, Uint8Array, function?): Disc} options.loader - Function to load this disc type.
20
29
  * @param {function(Disc): Uint8Array} options.saver - Function to save this disc type.
21
30
  * @param {function(Uint8Array, string): void|null} [options.nameSetter] - Function to set the name/label in the disc image, or null if not supported.
31
+ * @param {function(Uint8Array): {is40Track: boolean, reason: string}} [options.layoutSniffer] - Function to tell a 40 track image from an 80 track one, for formats that carry the evidence.
32
+ * @param {boolean} [options.isFluxImage] - Whether the image is a picture of the surface rather than a list of the sectors on it.
22
33
  * @param {boolean} [options.isDoubleSided] - Whether the disc format is double-sided.
23
34
  * @param {boolean} [options.isDoubleDensity] - Whether the disc format is double density.
24
35
  * @param {number|undefined} [options.byteSize] - The size in bytes of this disc format, or undefined if variable.
25
36
  */
26
- constructor({ extension, loader, saver, nameSetter, isDoubleSided, isDoubleDensity, byteSize } = {}) {
37
+ constructor({
38
+ extension,
39
+ loader,
40
+ saver,
41
+ nameSetter = null,
42
+ layoutSniffer,
43
+ isFluxImage,
44
+ isDoubleSided,
45
+ isDoubleDensity,
46
+ byteSize,
47
+ } = {}) {
27
48
  this._extension = extension;
28
49
  this._loader = loader;
29
50
  this._saver = saver;
30
51
  this._nameSetter = nameSetter;
52
+ this._layoutSniffer = layoutSniffer;
53
+ this._isFluxImage = isFluxImage;
31
54
  this._isDoubleSided = isDoubleSided;
32
55
  this._isDoubleDensity = isDoubleDensity;
33
56
  this._byteSize = byteSize;
@@ -97,6 +120,24 @@ export class DiscType {
97
120
  return this._nameSetter !== null;
98
121
  }
99
122
 
123
+ /**
124
+ * Whether the image holds a picture of the surface, so that where its tracks sit is a fact
125
+ * about the disc rather than a decision the loader made.
126
+ * @returns {boolean}
127
+ */
128
+ get isFluxImage() {
129
+ return !!this._isFluxImage;
130
+ }
131
+
132
+ /**
133
+ * What an image of this type says about its track layout.
134
+ * @param {Uint8Array} data - The disc image data
135
+ * @returns {?{is40Track: boolean, reason: string}} null for formats that cannot say
136
+ */
137
+ sniffLayout(data) {
138
+ return this._layoutSniffer ? this._layoutSniffer(data) : null;
139
+ }
140
+
100
141
  /**
101
142
  * Sets the disc name in the disc data using the format-specific setter
102
143
  * @param {Uint8Array} data - The disc data to modify
@@ -114,8 +155,9 @@ export class DiscType {
114
155
  // Standard sizes
115
156
  const SsdByteSize = 80 * 10 * 256; // 80 tracks, 10 sectors, 256 bytes/sector
116
157
  const DsdByteSize = SsdByteSize * 2; // Double-sided
117
- const AdfsLargeByteSize = 2 * 80 * 16 * 256; // Double-sided, 16 sectors/track
118
- const AdfsSmallByteSize = 80 * 16 * 256; // Single-sided, 16 sectors/track
158
+ // ADFS comes in three sizes: S is 40 tracks on one side, M is 80 on one, L is 80 on both.
159
+ const AdfsMediumByteSize = 80 * 16 * 256;
160
+ const AdfsLargeByteSize = AdfsMediumByteSize * 2;
119
161
 
120
162
  /**
121
163
  * Set the name in a DFS disc image (SSD/DSD format)
@@ -133,6 +175,8 @@ const hfeDiscType = new DiscType({
133
175
  extension: ".hfe",
134
176
  loader: loadHfe,
135
177
  saver: toHfe,
178
+ layoutSniffer: sniffHfeLayout,
179
+ isFluxImage: true,
136
180
  isDoubleSided: true,
137
181
  isDoubleDensity: true,
138
182
  });
@@ -152,7 +196,7 @@ const adlDiscType = new DiscType({
152
196
  byteSize: AdfsLargeByteSize,
153
197
  });
154
198
 
155
- // ADFS (Small) discs are standard ADFS (non-double) density, single sided
199
+ // ADFS M and S: single sided, and sized as the larger of the two.
156
200
  const adfDiscType = new DiscType({
157
201
  extension: ".adf",
158
202
  loader: (disc, data, _onChange) => {
@@ -164,7 +208,7 @@ const adfDiscType = new DiscType({
164
208
  },
165
209
  isDoubleSided: false,
166
210
  isDoubleDensity: true,
167
- byteSize: AdfsSmallByteSize,
211
+ byteSize: AdfsMediumByteSize,
168
212
  });
169
213
 
170
214
  // DSD (Double-sided disc)
@@ -173,6 +217,7 @@ const dsdDiscType = new DiscType({
173
217
  loader: (disc, data, onChange) => loadSsd(disc, data, true, onChange),
174
218
  saver: toSsdOrDsd,
175
219
  nameSetter: setDfsDiscName,
220
+ layoutSniffer: (data) => sniffDfsLayout(data, true),
176
221
  isDoubleSided: true,
177
222
  isDoubleDensity: false,
178
223
  byteSize: DsdByteSize,
@@ -184,6 +229,7 @@ const ssdDiscType = new DiscType({
184
229
  loader: (disc, data, onChange) => loadSsd(disc, data, false, onChange),
185
230
  saver: toSsdOrDsd,
186
231
  nameSetter: setDfsDiscName,
232
+ layoutSniffer: (data) => sniffDfsLayout(data, false),
187
233
  isDoubleSided: false,
188
234
  isDoubleDensity: false,
189
235
  byteSize: SsdByteSize,
@@ -202,22 +248,57 @@ export function guessDiscTypeFromName(name) {
202
248
  return ssdDiscType;
203
249
  }
204
250
 
251
+ /**
252
+ * @param {DiscType} discType
253
+ * @param {Uint8Array} data
254
+ * @param {string} name
255
+ * @param {string} layout - one of DiscLayout
256
+ * @returns {boolean} whether to lay the image out for a 40 track drive
257
+ */
258
+ function is40TrackLayout(discType, data, name, layout) {
259
+ if (layout !== DiscLayout.auto) return layout === DiscLayout.expanded40;
260
+ const sniffed = discType.sniffLayout(data);
261
+ if (!sniffed) return false;
262
+ console.log(`${name} loaded as ${sniffed.is40Track ? "40" : "80"} track: ${sniffed.reason}`);
263
+ return sniffed.is40Track;
264
+ }
265
+
205
266
  /**
206
267
  * Create a disc object of the appropriate type based on the file name
207
268
  * @param {Object} fdc - The FDC controller object
208
269
  * @param {string} name - The file name with extension
209
270
  * @param {string|Uint8Array} stringData - The disc image data as string or Uint8Array
210
271
  * @param {function(Uint8Array): void} onChange - Optional callback when disc content changes
272
+ * @param {string} [layout] - one of DiscLayout; by default the image is asked what it is
211
273
  * @returns {Disc} The loaded disc object
212
274
  */
213
- export function discFor(fdc, name, stringData, onChange) {
275
+ export function discFor(fdc, name, stringData, onChange, layout = DiscLayout.auto) {
214
276
  const data = typeof stringData !== "string" ? stringData : utils.stringToUint8Array(stringData);
215
- const disc = guessDiscTypeFromName(name).loader(new Disc(true, new DiscConfig(), name), data, onChange);
277
+ const discType = guessDiscTypeFromName(name);
278
+ const config = new DiscConfig();
279
+ config.expandTo80 = is40TrackLayout(discType, data, name, layout);
280
+ const disc = discType.loader(new Disc(true, config, name), data, onChange);
281
+ // A flux image of a 40 track disc read in an 80 track drive already holds it spread across the
282
+ // surface, so nothing needs moving and only the drive needs telling to step twice.
283
+ if (layout === DiscLayout.auto && discType.isFluxImage && !disc.is40Track) {
284
+ const sniffed = sniffSurfaceLayout(disc);
285
+ disc.is40Track = sniffed.is40Track;
286
+ console.log(`${name} surface reads as ${sniffed.is40Track ? "40" : "80"} track: ${sniffed.reason}`);
287
+ }
216
288
  disc.setOriginalImageCrc32(data instanceof Uint8Array ? data : new Uint8Array(data));
217
289
  return disc;
218
290
  }
219
291
 
220
- export function localDisc(fdc, name) {
292
+ /**
293
+ * Create or open a disc held in the browser's local storage.
294
+ * @param {Object} fdc - The FDC controller object
295
+ * @param {string} name - The file name with extension
296
+ * @param {string} [layout] - one of DiscLayout; by default the image is asked what it is
297
+ * @param {function(*): void} [onSaveError] - called with whatever was thrown, the first time a write
298
+ * cannot be stored
299
+ * @returns {Disc} The loaded disc object
300
+ */
301
+ export function localDisc(fdc, name, layout = DiscLayout.auto, onSaveError = () => {}) {
221
302
  const discName = "disc_" + name;
222
303
  let data;
223
304
  const dataString = window.localStorage[discName];
@@ -235,13 +316,17 @@ export function localDisc(fdc, name) {
235
316
  console.log("Loading browser-local disc " + name);
236
317
  data = utils.stringToUint8Array(dataString);
237
318
  }
319
+ let reportedSaveError = false;
238
320
  const onChange = (data) => {
239
321
  try {
240
322
  const str = utils.uint8ArrayToString(data);
241
323
  window.localStorage.setItem(discName, str);
242
324
  } catch (e) {
243
- window.alert("Writing to localStorage failed: " + e);
325
+ console.log(`Unable to save browser-local disc ${name}: ${e}`);
326
+ if (reportedSaveError) return;
327
+ reportedSaveError = true;
328
+ onSaveError(e);
244
329
  }
245
330
  };
246
- return discFor(fdc, name, data, onChange);
331
+ return discFor(fdc, name, data, onChange, layout);
247
332
  }
@@ -49,10 +49,11 @@ export class GoogleDriveLoader {
49
49
 
50
50
  _loadScript(src) {
51
51
  // https://github.com/google/google-api-javascript-client/issues/319
52
- return new Promise((resolve) => {
52
+ return new Promise((resolve, reject) => {
53
53
  const script = document.createElement("script");
54
54
  script.src = src;
55
55
  script.onload = resolve;
56
+ script.onerror = () => reject(new Error(`Failed to fetch ${src}; a browser extension may be blocking it`));
56
57
  document.body.appendChild(script);
57
58
  });
58
59
  }
@@ -134,7 +135,7 @@ export class GoogleDriveLoader {
134
135
  return { fileId: meta.id, disc: this.makeDisc(fdc, data, meta) };
135
136
  }
136
137
 
137
- makeDisc(fdc, data, meta) {
138
+ makeDisc(fdc, data, meta, layout) {
138
139
  let flusher = null;
139
140
  const name = meta.name;
140
141
  const id = meta.id;
@@ -148,12 +149,12 @@ export class GoogleDriveLoader {
148
149
  } else {
149
150
  console.log("Making read-only disc");
150
151
  }
151
- return discFor(fdc, name, data, flusher);
152
+ return discFor(fdc, name, data, flusher, layout);
152
153
  }
153
154
 
154
- async load(fdc, fileId) {
155
+ async load(fdc, fileId, layout) {
155
156
  const meta = (await this.driveClient.files.get({ fileId, fields: FILE_FIELDS })).result;
156
157
  const data = (await this.driveClient.files.get({ fileId, alt: "media" })).body;
157
- return this.makeDisc(fdc, data, meta);
158
+ return this.makeDisc(fdc, data, meta, layout);
158
159
  }
159
160
  }
package/src/intel-fdc.js CHANGED
@@ -1741,8 +1741,15 @@ export class IntelFdc {
1741
1741
  * @param {Number} drive
1742
1742
  * @param {Disc} disc
1743
1743
  */
1744
- loadDisc(drive, disc) {
1744
+ /**
1745
+ * @param {Number} drive
1746
+ * @param {Disc} disc
1747
+ * @param {Number} [tracksPerStep] where to leave the drive's 40/80 switch, which by default
1748
+ * follows the disc, since no drive can tell what pitch the disc in it was written at
1749
+ */
1750
+ loadDisc(drive, disc, tracksPerStep = disc?.is40Track ? 2 : 1) {
1745
1751
  this._drives[drive].setDisc(disc);
1752
+ this._drives[drive].tracksPerStep = tracksPerStep;
1746
1753
  }
1747
1754
 
1748
1755
  get motorOn() {
package/src/jsbeeb.css CHANGED
@@ -162,6 +162,54 @@ th {
162
162
  overflow: auto;
163
163
  }
164
164
 
165
+ #hfe-list {
166
+ height: 380px;
167
+ overflow: auto;
168
+ list-style: none;
169
+ padding-left: 0;
170
+ }
171
+
172
+ #hfe-list li a {
173
+ display: grid;
174
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr);
175
+ gap: 0 1rem;
176
+ align-items: baseline;
177
+ padding: 3px 6px;
178
+ border-radius: 3px;
179
+ text-decoration: none;
180
+ }
181
+
182
+ #hfe-list li a:hover,
183
+ #hfe-list li a:focus-visible {
184
+ background: rgba(255, 255, 255, 0.08);
185
+ }
186
+
187
+ #hfe-list .name {
188
+ font-weight: 600;
189
+ }
190
+
191
+ #hfe-list .publisher,
192
+ #hfe-list .detail {
193
+ opacity: 0.65;
194
+ font-size: 0.9em;
195
+ }
196
+
197
+ #hfe-list .detail {
198
+ font-family: monospace;
199
+ }
200
+
201
+ .hfe-credit {
202
+ font-size: 0.9em;
203
+ opacity: 0.75;
204
+ }
205
+
206
+ @media (max-width: 40rem) {
207
+ #hfe-list li a {
208
+ grid-template-columns: minmax(0, 1fr);
209
+ padding-bottom: 6px;
210
+ }
211
+ }
212
+
165
213
  div.filter {
166
214
  margin-top: 8px;
167
215
  }
@@ -576,3 +624,20 @@ div.smoothie-chart-tooltip {
576
624
  font-size: 10px;
577
625
  pointer-events: none;
578
626
  }
627
+
628
+ .drive-tracks {
629
+ display: flex;
630
+ align-items: center;
631
+ gap: 6px;
632
+ white-space: nowrap;
633
+ }
634
+
635
+ .drive-tracks-label {
636
+ color: var(--bs-dropdown-link-color);
637
+ }
638
+
639
+ .drive-tracks .btn {
640
+ /* The stock small button is taller than the menu rows around it. */
641
+ padding: 0 6px;
642
+ line-height: 1.4;
643
+ }
package/src/keyboard.js CHANGED
@@ -43,6 +43,7 @@ export class Keyboard extends EventTarget {
43
43
  this.pauseEmu = false;
44
44
  this.stepEmuWhenPaused = false;
45
45
  this.keyLayout = keyLayout;
46
+ this.saidCapsLockIsTapped = false;
46
47
 
47
48
  // Modifier key states
48
49
  this.lastShiftLocation = 1;
@@ -315,21 +316,19 @@ export class Keyboard extends EventTarget {
315
316
  // Simulate a key release after a short delay
316
317
  setTimeout(() => this.keyInterface.keyUp(utils.keyCodes.CAPSLOCK), CAPS_LOCK_DELAY);
317
318
 
318
- if (isMac && window.localStorage && !window.localStorage.getItem("warnedAboutRubbishMacs")) {
319
- this.dispatchEvent(
320
- new CustomEvent("showError", {
321
- detail: {
322
- context: "handling caps lock on Mac OS X",
323
- error: `Mac OS X does not generate key up events for caps lock presses.
324
- jsbeeb can only simulate a 'tap' of the caps lock key. This means it doesn't work well for games
325
- that use caps lock for left or fire, as we can't tell if it's being held down. If you need to play
326
- such a game, please see the documentation about remapping keys.
327
- Close this window to continue (you won't see this error again)`,
328
- },
329
- }),
330
- );
331
- window.localStorage.setItem("warnedAboutRubbishMacs", "true");
332
- }
319
+ if (this.saidCapsLockIsTapped) return;
320
+ this.saidCapsLockIsTapped = true;
321
+ this.dispatchEvent(
322
+ new CustomEvent("notice", {
323
+ detail: {
324
+ message:
325
+ "macOS sends no key up for caps lock, so jsbeeb can only tap it. " +
326
+ "For a game that holds caps lock for left or fire, remap that key instead.",
327
+ title: "Keyboard",
328
+ quietKey: "warnedAboutRubbishMacs",
329
+ },
330
+ }),
331
+ );
333
332
  }
334
333
 
335
334
  /**
@@ -410,7 +410,8 @@ export class MachineSession {
410
410
  */
411
411
  loadDisc(imagePath) {
412
412
  const data = new Uint8Array(readFileSync(imagePath));
413
- this._machine.processor.fdc.loadDisc(0, fdc.discFor(this._machine.processor.fdc, imagePath, data));
413
+ const machineFdc = this._machine.processor.fdc;
414
+ machineFdc.loadDisc(0, fdc.discFor(machineFdc, imagePath, data));
414
415
  }
415
416
 
416
417
  /**