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/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,48 @@ 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
+ export function localDisc(fdc, name, layout = DiscLayout.auto) {
221
293
  const discName = "disc_" + name;
222
294
  let data;
223
295
  const dataString = window.localStorage[discName];
@@ -243,5 +315,5 @@ export function localDisc(fdc, name) {
243
315
  window.alert("Writing to localStorage failed: " + e);
244
316
  }
245
317
  };
246
- return discFor(fdc, name, data, onChange);
318
+ return discFor(fdc, name, data, onChange, layout);
247
319
  }
@@ -134,7 +134,7 @@ export class GoogleDriveLoader {
134
134
  return { fileId: meta.id, disc: this.makeDisc(fdc, data, meta) };
135
135
  }
136
136
 
137
- makeDisc(fdc, data, meta) {
137
+ makeDisc(fdc, data, meta, layout) {
138
138
  let flusher = null;
139
139
  const name = meta.name;
140
140
  const id = meta.id;
@@ -148,12 +148,12 @@ export class GoogleDriveLoader {
148
148
  } else {
149
149
  console.log("Making read-only disc");
150
150
  }
151
- return discFor(fdc, name, data, flusher);
151
+ return discFor(fdc, name, data, flusher, layout);
152
152
  }
153
153
 
154
- async load(fdc, fileId) {
154
+ async load(fdc, fileId, layout) {
155
155
  const meta = (await this.driveClient.files.get({ fileId, fields: FILE_FIELDS })).result;
156
156
  const data = (await this.driveClient.files.get({ fileId, alt: "media" })).body;
157
- return this.makeDisc(fdc, data, meta);
157
+ return this.makeDisc(fdc, data, meta, layout);
158
158
  }
159
159
  }
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
  /**