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 +8 -2
- package/package.json +8 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/canvas.js +9 -2
- package/src/disc-drive.js +45 -12
- package/src/disc-hfe.js +18 -1
- package/src/disc.js +132 -20
- package/src/fdc.js +83 -11
- package/src/google-drive.js +4 -4
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +65 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +324 -139
- package/src/models.js +2 -0
- package/src/sth.js +5 -5
- package/src/teletext_adaptor.js +6 -4
- package/src/url-params.js +28 -0
- package/src/utils.js +3 -0
- package/src/wd-fdc.js +8 -1
- package/src/web/audio-handler.js +4 -2
- package/src/web/toast.js +79 -0
- package/tests/test-machine.js +1 -1
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
|
|
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.
|
|
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"),
|
package/src/bbcdiscs.js
ADDED
|
@@ -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
|
-
|
|
164
|
-
//
|
|
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
|
-
|
|
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.
|
|
377
|
+
this.notifySeekAmount(newTrack - this.logicalTrack);
|
|
351
378
|
}
|
|
352
379
|
|
|
353
380
|
/**
|
|
354
|
-
* Notify that an overall seek is happening by some delta
|
|
381
|
+
* Notify that an overall seek is happening by some delta amount. Purely informational.
|
|
355
382
|
*/
|
|
356
383
|
notifySeekAmount(delta) {
|
|
357
|
-
|
|
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
|
|
369
|
-
track =
|
|
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)
|
|
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.
|
|
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.
|
|
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[
|
|
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 <
|
|
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,
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
782
|
-
|
|
783
|
-
|
|
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,
|
|
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
|
/**
|