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/src/fdc.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// Floppy disc assorted utils.
|
|
2
|
-
import {
|
|
3
|
-
|
|
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({
|
|
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
|
-
|
|
118
|
-
const
|
|
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
|
|
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:
|
|
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
|
|
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
|
}
|
package/src/google-drive.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
/**
|
package/src/machine-session.js
CHANGED
|
@@ -410,7 +410,8 @@ export class MachineSession {
|
|
|
410
410
|
*/
|
|
411
411
|
loadDisc(imagePath) {
|
|
412
412
|
const data = new Uint8Array(readFileSync(imagePath));
|
|
413
|
-
|
|
413
|
+
const machineFdc = this._machine.processor.fdc;
|
|
414
|
+
machineFdc.loadDisc(0, fdc.discFor(machineFdc, imagePath, data));
|
|
414
415
|
}
|
|
415
416
|
|
|
416
417
|
/**
|