jsbeeb 1.16.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/package.json +9 -1
- package/src/6502.js +2 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/bem-snapshot.js +3 -4
- package/src/canvas.js +9 -2
- package/src/cmos.js +38 -0
- package/src/disc-drive.js +45 -12
- package/src/disc-hfe.js +19 -2
- package/src/disc.js +154 -22
- package/src/fdc.js +97 -12
- package/src/google-drive.js +6 -5
- 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 +381 -170
- package/src/models.js +2 -0
- package/src/printer.js +59 -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 +28 -55
- package/src/via.js +7 -1
- package/src/wd-fdc.js +8 -1
- package/src/web/audio-handler.js +27 -8
- package/src/web/toast.js +83 -0
- package/tests/test-machine.js +1 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ different peripherals.
|
|
|
13
13
|
- [Keyboard Mappings](#keyboard-mappings)
|
|
14
14
|
- [Remapping Keys](#remapping-keys)
|
|
15
15
|
- [Emulator Shortcuts](#emulator-shortcuts)
|
|
16
|
+
- [Printer Output](#printer-output)
|
|
16
17
|
- [Save State and Rewind](#save-state-and-rewind)
|
|
17
18
|
- [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
|
|
18
19
|
- [Running as a Desktop Application](#running-as-a-desktop-application)
|
|
@@ -103,8 +104,15 @@ The definitive lists are `keyCodes` (host) and `BBC` (BBC micro) in [`src/utils.
|
|
|
103
104
|
| `Ctrl+Home` | Stop and enter debugger |
|
|
104
105
|
| `Ctrl+Insert` | Toggle turbo (fast-as-possible) |
|
|
105
106
|
| `Ctrl+End` | Pause emulation |
|
|
107
|
+
| `Ctrl+B` | Open printer output window |
|
|
106
108
|
| `Alt+PageDown` | Open rewind scrubber |
|
|
107
109
|
|
|
110
|
+
### Printer Output
|
|
111
|
+
|
|
112
|
+
Anything the machine prints is captured whether or not the printer window is open, so programs that print (with `VDU 2`, `*FX5,1` and the like) run rather than waiting for a printer that is not there.
|
|
113
|
+
|
|
114
|
+
Press `Ctrl+B` to open a window showing what has been printed so far; it then keeps up with the output as it arrives. Only the most recent output is kept, roughly a dozen pages' worth, so a program printing forever cannot fill memory.
|
|
115
|
+
|
|
108
116
|
### Save State and Rewind
|
|
109
117
|
|
|
110
118
|
Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S` / `Ctrl+O` in the Electron app).
|
|
@@ -240,10 +248,16 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
|
|
|
240
248
|
## URL Parameters
|
|
241
249
|
|
|
242
250
|
- `autoboot` - fakes a shift break
|
|
243
|
-
- `disc1=XXX` - loads disc XXX (from the `discs/` directory) into drive
|
|
244
|
-
- `disc2=XXX` - as above
|
|
251
|
+
- `disc1=XXX` - loads disc XXX (from the `discs/` directory) into drive 0
|
|
252
|
+
- `disc2=XXX` - as above, into drive 1
|
|
245
253
|
- `disc1=local:YYY` - creates a local disk YYY which will be kept in browser local storage
|
|
246
254
|
- `disc1=sth:ZZZ` - loads disc ZZZ from the Stairway to Hell archive
|
|
255
|
+
- `drive0Tracks=40` / `drive0Tracks=80` - fixes drive 0's 40/80 track switch, as the switch on the back of a real
|
|
256
|
+
drive did. `drive1Tracks` does the same for drive 1. Left alone, each drive follows whatever disc is loaded into it:
|
|
257
|
+
a 40 track image is laid out the way a 40 track drive wrote it, on every other track of the surface, and the drive
|
|
258
|
+
double steps to read it. `40` reads an 80 track disc through a double stepping head, which is as much of a mess as it
|
|
259
|
+
was in 1985. `80` turns all of this off for that drive, loading every image the way jsbeeb did before it could tell
|
|
260
|
+
them apart. [docs/disc-track-layouts.md](docs/disc-track-layouts.md) explains how an image's layout is worked out.
|
|
247
261
|
- `tape=XXX` - loads tape XXX (from the `tapes/` directory)
|
|
248
262
|
- `tape=sth:ZZZ` - loads tape ZZZ from the Stairway to Hell archive
|
|
249
263
|
- `KEY.X=Y` - makes host key `X` press BBC key `Y`, e.g. `KEY.ENTER=COPY`. See
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"name": "jsbeeb",
|
|
8
8
|
"description": "Emulate a BBC Micro",
|
|
9
9
|
"repository": "git@github.com:mattgodbolt/jsbeeb.git",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.17.1",
|
|
11
11
|
"//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=24.15.0"
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"argparse": "^3.0.0",
|
|
31
31
|
"bootstrap": "^5.3.8",
|
|
32
32
|
"bootswatch": "^5.3.8",
|
|
33
|
+
"pako": "^3.0.1",
|
|
33
34
|
"sharp": "^0.35.3",
|
|
34
35
|
"smoothie": "^1.36.1"
|
|
35
36
|
},
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"jsdom": "^30.0.1",
|
|
45
46
|
"lint-staged": "^17.1.1",
|
|
46
47
|
"npm-run-all2": "^9.0.2",
|
|
48
|
+
"papaparse": "^5.5.4",
|
|
47
49
|
"pixelmatch": "^7.2.0",
|
|
48
50
|
"prettier": "^3.9.6",
|
|
49
51
|
"vite": "^8.1.5",
|
|
@@ -104,6 +106,12 @@
|
|
|
104
106
|
"mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
|
|
105
107
|
"mirror-sth:upload:blobs": "aws s3 sync .sth-mirror s3://bbc.xania.org/archive/sth/ --no-progress --exclude '*manifest.json' --exclude 'meta/*' --cache-control 'public, max-age=31536000, immutable'",
|
|
106
108
|
"mirror-sth:upload:index": "aws s3 sync .sth-mirror s3://bbc.xania.org/archive/sth/ --no-progress --exclude '*' --include '*manifest.json' --include 'meta/*' --cache-control 'public, max-age=300'",
|
|
109
|
+
"mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --out .bbcdiscs-mirror",
|
|
110
|
+
"mirror-bbcdiscs:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
|
|
111
|
+
"mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
|
|
112
|
+
"mirror-bbcdiscs:upload": "npm-run-all mirror-bbcdiscs:upload:*",
|
|
113
|
+
"mirror-bbcdiscs:upload:blobs": "aws s3 sync .bbcdiscs-mirror/hfe s3://bbc.xania.org/archive/bbcdiscs/hfe/ --no-progress --exclude 'manifest.json' --delete --content-encoding br --cache-control 'public, max-age=31536000, immutable'",
|
|
114
|
+
"mirror-bbcdiscs:upload:index": "aws s3 cp .bbcdiscs-mirror/hfe/manifest.json s3://bbc.xania.org/archive/bbcdiscs/hfe/manifest.json --cache-control 'public, max-age=300' && aws s3 cp .bbcdiscs-mirror/manifest.json s3://bbc.xania.org/archive/bbcdiscs/manifest.json --cache-control 'public, max-age=300'",
|
|
107
115
|
"electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
|
|
108
116
|
"electron:build": "electron-builder"
|
|
109
117
|
},
|
package/src/6502.js
CHANGED
|
@@ -1359,7 +1359,8 @@ export class Cpu6502 extends Base6502 {
|
|
|
1359
1359
|
// Override in subclasses for different peripheral sets.
|
|
1360
1360
|
// Only called on hard reset.
|
|
1361
1361
|
resetPeripherals() {
|
|
1362
|
-
if (this.config.printerPort)
|
|
1362
|
+
if (this.config.printerPort)
|
|
1363
|
+
this.uservia.ca2changecallback = (level, output) => this.config.printerPort.outputStrobe(level, output);
|
|
1363
1364
|
|
|
1364
1365
|
this.sysvia.reset();
|
|
1365
1366
|
this.uservia.reset();
|
package/src/app/app.js
CHANGED
|
@@ -172,6 +172,10 @@ const template = [
|
|
|
172
172
|
label: "Browse STH Disc Archive...",
|
|
173
173
|
click: showModal("sth", { sthType: "discs" }),
|
|
174
174
|
},
|
|
175
|
+
{
|
|
176
|
+
label: "Browse HFE Disc Archive...",
|
|
177
|
+
click: showModal("hfe"),
|
|
178
|
+
},
|
|
175
179
|
{
|
|
176
180
|
label: "Browse Example Discs...",
|
|
177
181
|
click: showModal("discs"),
|
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/bem-snapshot.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
import {
|
|
2
|
+
import { inflate } from "./utils.js";
|
|
3
3
|
|
|
4
4
|
// B-em snapshot format parser (versions 1 and 3).
|
|
5
5
|
// v1 (BEMSNAP1): Fixed-size 327,885 byte packed struct. Reference: beebjit state.c
|
|
@@ -334,7 +334,7 @@ async function parseBemTube(sections, tubeName) {
|
|
|
334
334
|
|
|
335
335
|
// Those names come from a config file, so size is the real check. It cannot be exact: the
|
|
336
336
|
// trailing ROM's size comes from that same config, and b-em records it as zero if unset.
|
|
337
|
-
const parasite =
|
|
337
|
+
const parasite = inflate(sections["P"].data);
|
|
338
338
|
const romBytes = parasite.length - BemTubeRegisterBytes - BemTubeRamSize;
|
|
339
339
|
if (romBytes < 0 || romBytes > BemTubeRamSize) {
|
|
340
340
|
throw new Error(
|
|
@@ -449,9 +449,8 @@ async function parseBemV3(buffer) {
|
|
|
449
449
|
let roms = null;
|
|
450
450
|
const memSection = sections["M"];
|
|
451
451
|
if (memSection) {
|
|
452
|
-
// Memory is zlib-compressed; decompression is async.
|
|
453
452
|
// Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
|
|
454
|
-
const memData =
|
|
453
|
+
const memData = inflate(memSection.data);
|
|
455
454
|
cpuState.fe30 = memData[0];
|
|
456
455
|
cpuState.fe34 = memData[1];
|
|
457
456
|
const ramStart = 2;
|
package/src/canvas.js
CHANGED
|
@@ -213,11 +213,18 @@ export class GlCanvas {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
function fellBackBecause(canvas, reason) {
|
|
217
|
+
canvas.fallbackReason = reason;
|
|
218
|
+
return canvas;
|
|
219
|
+
}
|
|
220
|
+
|
|
216
221
|
export function bestCanvas(canvas, filterClass) {
|
|
222
|
+
let reason;
|
|
217
223
|
try {
|
|
218
224
|
return new GlCanvas(canvas, filterClass);
|
|
219
225
|
} catch (e) {
|
|
220
226
|
// Either WebGL is unavailable or this particular filter declined it.
|
|
227
|
+
reason = e?.message ?? e;
|
|
221
228
|
console.log(`Unable to use ${filterClass.getDisplayConfig().name} with WebGL: ${e}`);
|
|
222
229
|
}
|
|
223
230
|
|
|
@@ -226,11 +233,11 @@ export function bestCanvas(canvas, filterClass) {
|
|
|
226
233
|
// 2D fallback below would throw and take the emulator with it.
|
|
227
234
|
if (filterClass !== PassthroughFilter) {
|
|
228
235
|
try {
|
|
229
|
-
return new GlCanvas(canvas, PassthroughFilter);
|
|
236
|
+
return fellBackBecause(new GlCanvas(canvas, PassthroughFilter), reason);
|
|
230
237
|
} catch (e) {
|
|
231
238
|
console.log("Unable to fall back to the passthrough filter: " + e);
|
|
232
239
|
}
|
|
233
240
|
}
|
|
234
241
|
|
|
235
|
-
return new Canvas(canvas);
|
|
242
|
+
return fellBackBecause(new Canvas(canvas), reason);
|
|
236
243
|
}
|
package/src/cmos.js
CHANGED
|
@@ -33,6 +33,44 @@ function fromBcd(value) {
|
|
|
33
33
|
|
|
34
34
|
export { defaultCmos };
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* CMOS persistence backed by a browser storage object, which can be unavailable, full or holding
|
|
38
|
+
* something other than what was last saved.
|
|
39
|
+
*
|
|
40
|
+
* @param {function(): Storage} getStorage typically `() => window.localStorage`. Reading that
|
|
41
|
+
* property is itself what throws when a page is refused storage, so it happens per call, inside
|
|
42
|
+
* the try.
|
|
43
|
+
* @param {function(*)} onSaveFailure called with the error the first time a save fails
|
|
44
|
+
*/
|
|
45
|
+
export function localStoragePersistence(getStorage, onSaveFailure) {
|
|
46
|
+
let saveFailureReported = false;
|
|
47
|
+
return {
|
|
48
|
+
load() {
|
|
49
|
+
try {
|
|
50
|
+
const stored = getStorage().cmosRam;
|
|
51
|
+
if (!stored) return null;
|
|
52
|
+
const parsed = JSON.parse(stored);
|
|
53
|
+
if (!Array.isArray(parsed) || parsed.length !== defaultCmos.length)
|
|
54
|
+
throw new Error(`the stored settings are not ${defaultCmos.length} bytes`);
|
|
55
|
+
return parsed;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.log(`Unable to read the stored CMOS settings: ${error?.message ?? error}`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
save(data) {
|
|
62
|
+
try {
|
|
63
|
+
getStorage().cmosRam = JSON.stringify(data);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.log(`Unable to store the CMOS settings: ${error?.message ?? error}`);
|
|
66
|
+
if (saveFailureReported) return;
|
|
67
|
+
saveFailureReported = true;
|
|
68
|
+
onSaveFailure(error);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
36
74
|
export class Cmos {
|
|
37
75
|
constructor(persistence, cmosOverride, econet) {
|
|
38
76
|
this.store = persistence ? persistence.load() : null;
|
package/src/disc-drive.js
CHANGED
|
@@ -38,6 +38,16 @@ export class BaseDiscDrive extends EventTarget {
|
|
|
38
38
|
throw new Error("Not implemented: isSideUpper getter");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** @returns {Number} */
|
|
42
|
+
get tracksPerStep() {
|
|
43
|
+
throw new Error("Not implemented: tracksPerStep getter");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {Number} _tracksPerStep */
|
|
47
|
+
set tracksPerStep(_tracksPerStep) {
|
|
48
|
+
throw new Error("Not implemented: tracksPerStep setter");
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
/** @returns {boolean} */
|
|
42
52
|
get indexPulse() {
|
|
43
53
|
throw new Error("Not implemented: indexPulse getter");
|
|
@@ -160,8 +170,10 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
160
170
|
this._scheduler = scheduler;
|
|
161
171
|
/** @type {Disc|undefined} */
|
|
162
172
|
this._disc = undefined;
|
|
163
|
-
|
|
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
|
|
|
@@ -196,7 +213,7 @@ export function loadHfe(disc, data, onChange) {
|
|
|
196
213
|
const hfeData = toHfe(disc);
|
|
197
214
|
// Call the onChange handler with the updated HFE data
|
|
198
215
|
onChange(hfeData);
|
|
199
|
-
});
|
|
216
|
+
}, true);
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
return disc;
|