jsbeeb 1.17.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 +8 -0
- package/package.json +2 -1
- package/src/6502.js +2 -1
- package/src/bem-snapshot.js +3 -4
- package/src/cmos.js +38 -0
- package/src/disc-hfe.js +1 -1
- package/src/disc.js +22 -2
- package/src/fdc.js +15 -2
- package/src/google-drive.js +2 -1
- package/src/main.js +62 -36
- package/src/printer.js +59 -0
- package/src/utils.js +25 -55
- package/src/via.js +7 -1
- package/src/web/audio-handler.js +26 -9
- package/src/web/toast.js +5 -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).
|
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.17.
|
|
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
|
},
|
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/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/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-hfe.js
CHANGED
package/src/disc.js
CHANGED
|
@@ -720,6 +720,7 @@ export function loadSsd(disc, data, isDsd, onChange) {
|
|
|
720
720
|
if (!sectorShortfall(sector)) dataCopy.set(sector.sectorData, ssdOffsetOf(sector, side, numSides));
|
|
721
721
|
onChange(dataCopy);
|
|
722
722
|
},
|
|
723
|
+
true,
|
|
723
724
|
);
|
|
724
725
|
}
|
|
725
726
|
return disc;
|
|
@@ -905,6 +906,7 @@ export class Disc {
|
|
|
905
906
|
this.is40Track = false;
|
|
906
907
|
|
|
907
908
|
this._trackWriteListeners = new Set();
|
|
909
|
+
this._savingListeners = new Set();
|
|
908
910
|
this.isWriteable = isWriteable;
|
|
909
911
|
|
|
910
912
|
// Track which tracks have been written since the last snapshot.
|
|
@@ -931,14 +933,32 @@ export class Disc {
|
|
|
931
933
|
this.initSurface(0);
|
|
932
934
|
}
|
|
933
935
|
|
|
934
|
-
/**
|
|
935
|
-
|
|
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) {
|
|
936
941
|
this._trackWriteListeners.add(listener);
|
|
942
|
+
if (savesChanges) this._savingListeners.add(listener);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
get savesChanges() {
|
|
946
|
+
return this._savingListeners.size > 0;
|
|
937
947
|
}
|
|
938
948
|
|
|
939
949
|
/** @param {function(boolean, Number, Track): void} listener */
|
|
940
950
|
removeTrackWriteListener(listener) {
|
|
941
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);
|
|
942
962
|
}
|
|
943
963
|
|
|
944
964
|
/**
|
package/src/fdc.js
CHANGED
|
@@ -289,7 +289,16 @@ export function discFor(fdc, name, stringData, onChange, layout = DiscLayout.aut
|
|
|
289
289
|
return disc;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
|
|
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 = () => {}) {
|
|
293
302
|
const discName = "disc_" + name;
|
|
294
303
|
let data;
|
|
295
304
|
const dataString = window.localStorage[discName];
|
|
@@ -307,12 +316,16 @@ export function localDisc(fdc, name, layout = DiscLayout.auto) {
|
|
|
307
316
|
console.log("Loading browser-local disc " + name);
|
|
308
317
|
data = utils.stringToUint8Array(dataString);
|
|
309
318
|
}
|
|
319
|
+
let reportedSaveError = false;
|
|
310
320
|
const onChange = (data) => {
|
|
311
321
|
try {
|
|
312
322
|
const str = utils.uint8ArrayToString(data);
|
|
313
323
|
window.localStorage.setItem(discName, str);
|
|
314
324
|
} catch (e) {
|
|
315
|
-
|
|
325
|
+
console.log(`Unable to save browser-local disc ${name}: ${e}`);
|
|
326
|
+
if (reportedSaveError) return;
|
|
327
|
+
reportedSaveError = true;
|
|
328
|
+
onSaveError(e);
|
|
316
329
|
}
|
|
317
330
|
};
|
|
318
331
|
return discFor(fdc, name, data, onChange, layout);
|
package/src/google-drive.js
CHANGED
|
@@ -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
|
}
|
package/src/main.js
CHANGED
|
@@ -10,7 +10,7 @@ import { Debugger } from "./web/debug.js";
|
|
|
10
10
|
import { Cpu6502, AtomCpu6502 } from "./6502.js";
|
|
11
11
|
import * as utils_atom from "./utils_atom.js";
|
|
12
12
|
import { LoadSD } from "./mmc.js";
|
|
13
|
-
import { Cmos } from "./cmos.js";
|
|
13
|
+
import { Cmos, localStoragePersistence } from "./cmos.js";
|
|
14
14
|
import { StairwayToHell } from "./sth.js";
|
|
15
15
|
import { BbcDiscArchive, describe as describeHfe } from "./bbcdiscs.js";
|
|
16
16
|
import { GamePad } from "./gamepads.js";
|
|
@@ -31,6 +31,7 @@ import { GamepadSource } from "./gamepad-source.js";
|
|
|
31
31
|
import { toast } from "./web/toast.js";
|
|
32
32
|
import { MicrophoneInput } from "./microphone-input.js";
|
|
33
33
|
import { SpeechOutput } from "./speech-output.js";
|
|
34
|
+
import { Printer } from "./printer.js";
|
|
34
35
|
import { MouseJoystickSource } from "./mouse-joystick-source.js";
|
|
35
36
|
import { calculateMouseCoordinates } from "./mouse-coordinates.js";
|
|
36
37
|
import { getFilterForMode } from "./canvas.js";
|
|
@@ -208,19 +209,16 @@ if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilt
|
|
|
208
209
|
if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
|
|
209
210
|
if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
|
|
210
211
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
if (!output || level) return;
|
|
215
|
-
|
|
216
|
-
const uservia = processor.uservia;
|
|
217
|
-
// Ack the character by pulsing CA1 low.
|
|
218
|
-
uservia.setca1(false);
|
|
219
|
-
uservia.setca1(true);
|
|
220
|
-
const newChar = String.fromCharCode(uservia.ora);
|
|
221
|
-
printerTextArea.value += newChar;
|
|
212
|
+
const printer = new Printer({
|
|
213
|
+
onOutput: (char) => {
|
|
214
|
+
if (printerTextArea) printerTextArea.value += char;
|
|
222
215
|
},
|
|
223
|
-
|
|
216
|
+
onFirstOutput: () =>
|
|
217
|
+
toast("Printer output is being kept. Press Ctrl-B to open the printer window.", {
|
|
218
|
+
title: "Printer",
|
|
219
|
+
quietKey: "quietPrinterOutput",
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
224
222
|
|
|
225
223
|
// Accessibility switch state — bits 0-7 correspond to switches 1-8.
|
|
226
224
|
// Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
|
|
@@ -335,7 +333,7 @@ const emulationConfig = {
|
|
|
335
333
|
// before any the user asked for with ?rom=.
|
|
336
334
|
extraRoms: [...config.extraRoms, ...extraRoms],
|
|
337
335
|
userPort,
|
|
338
|
-
printerPort,
|
|
336
|
+
printerPort: printer,
|
|
339
337
|
getGamepads: function () {
|
|
340
338
|
// Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
|
|
341
339
|
return navigator.getGamepads ? navigator.getGamepads() : [];
|
|
@@ -433,10 +431,25 @@ function putDiscIn(driveIndex, loadedDisc) {
|
|
|
433
431
|
const was = drive.tracksPerStep;
|
|
434
432
|
processor.fdc.loadDisc(driveIndex, loadedDisc, fixed);
|
|
435
433
|
showDriveTracks(driveIndex);
|
|
434
|
+
noteUnsavedWrites(loadedDisc);
|
|
436
435
|
// A switch the user fixed does not move, so anything it does is not news.
|
|
437
436
|
if (fixed === undefined && drive.tracksPerStep !== was) noteDriveTracks(driveIndex, loadedDisc.name);
|
|
438
437
|
}
|
|
439
438
|
|
|
439
|
+
let saidWritesAreNotKept = false;
|
|
440
|
+
|
|
441
|
+
function noteUnsavedWrites(loadedDisc) {
|
|
442
|
+
if (loadedDisc.savesChanges || saidWritesAreNotKept) return;
|
|
443
|
+
loadedDisc.notifyOnFirstTrackWrite(() => {
|
|
444
|
+
if (saidWritesAreNotKept) return;
|
|
445
|
+
saidWritesAreNotKept = true;
|
|
446
|
+
toast(`Changes to ${loadedDisc.name} are not saved. Use Discs, Download to keep a copy.`, {
|
|
447
|
+
title: "Disc",
|
|
448
|
+
quietKey: "quietDiscNotSaved",
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
440
453
|
const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
|
|
441
454
|
|
|
442
455
|
function showDriveTracks(driveIndex) {
|
|
@@ -725,17 +738,14 @@ if (config.hasEconet) {
|
|
|
725
738
|
}
|
|
726
739
|
|
|
727
740
|
const cmos = new Cmos(
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
window.localStorage.cmosRam = JSON.stringify(data);
|
|
737
|
-
},
|
|
738
|
-
},
|
|
741
|
+
localStoragePersistence(
|
|
742
|
+
() => window.localStorage,
|
|
743
|
+
(error) =>
|
|
744
|
+
toast(
|
|
745
|
+
`Settings changed with *CONFIGURE will not be kept (${errorText(error)}). Check that this site is allowed to store data, and that its storage is not full.`,
|
|
746
|
+
{ title: "Settings", quietKey: "quietCmosSave" },
|
|
747
|
+
),
|
|
748
|
+
),
|
|
739
749
|
model.cmosOverride,
|
|
740
750
|
econet,
|
|
741
751
|
);
|
|
@@ -747,12 +757,17 @@ function checkPrinterWindow() {
|
|
|
747
757
|
if (printerWindow && !printerWindow.closed) return;
|
|
748
758
|
|
|
749
759
|
printerWindow = window.open("", "_blank", "height=300,width=400");
|
|
760
|
+
if (!printerWindow) {
|
|
761
|
+
toast("The printer output window was blocked. Allow pop-up windows for this site, then press Ctrl-B again.", {
|
|
762
|
+
title: "Printer",
|
|
763
|
+
});
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
750
766
|
printerWindow.document.write(
|
|
751
767
|
'<textarea id="text" rows="15" cols="40" placeholder="Printer outputs here..."></textarea>',
|
|
752
768
|
);
|
|
753
769
|
printerTextArea = printerWindow.document.getElementById("text");
|
|
754
|
-
|
|
755
|
-
processor.uservia.setca1(true);
|
|
770
|
+
printerTextArea.value = printer.text;
|
|
756
771
|
}
|
|
757
772
|
|
|
758
773
|
const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
|
|
@@ -768,6 +783,8 @@ processor = new CpuClass(model, {
|
|
|
768
783
|
econet,
|
|
769
784
|
});
|
|
770
785
|
|
|
786
|
+
printer.attach(processor.uservia);
|
|
787
|
+
|
|
771
788
|
processor.teletextAdaptor?.addEventListener("notice", showNotice);
|
|
772
789
|
|
|
773
790
|
// Create input sources
|
|
@@ -1178,13 +1195,13 @@ async function hfeClick(file) {
|
|
|
1178
1195
|
const name = describeHfe(file).title;
|
|
1179
1196
|
popupLoading("Loading " + name);
|
|
1180
1197
|
try {
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1198
|
+
const loaded = await loadDiscImage(parsedQuery.disc1, layoutForDrive(0));
|
|
1199
|
+
putDiscIn(0, loaded);
|
|
1183
1200
|
loadingFinished();
|
|
1184
1201
|
if (needsAutoboot) autoboot(name);
|
|
1185
1202
|
} catch (err) {
|
|
1186
1203
|
console.error("Error loading disc image:", err);
|
|
1187
|
-
loadingFinished(err);
|
|
1204
|
+
loadingFinished(`Unable to load ${name} from the HFE archive: ${errorText(err)}`);
|
|
1188
1205
|
}
|
|
1189
1206
|
}
|
|
1190
1207
|
|
|
@@ -1352,7 +1369,12 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1352
1369
|
discImage = split.image;
|
|
1353
1370
|
const schema = split.schema;
|
|
1354
1371
|
if (schema[0] === "!" || schema === "local") {
|
|
1355
|
-
return disc.localDisc(processor.fdc, discImage, layout)
|
|
1372
|
+
return disc.localDisc(processor.fdc, discImage, layout, (error) =>
|
|
1373
|
+
toast(
|
|
1374
|
+
`Browser storage would not take changes to ${discImage} (${errorText(error)}). Use Discs, Download to keep a copy.`,
|
|
1375
|
+
{ title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
|
|
1376
|
+
),
|
|
1377
|
+
);
|
|
1356
1378
|
}
|
|
1357
1379
|
// TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
|
|
1358
1380
|
// Consider:
|
|
@@ -1368,7 +1390,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1368
1390
|
}
|
|
1369
1391
|
|
|
1370
1392
|
case "hfe":
|
|
1371
|
-
return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage));
|
|
1393
|
+
return disc.discFor(processor.fdc, discImage, await hfeArchive.fetch(discImage), undefined, layout);
|
|
1372
1394
|
|
|
1373
1395
|
case "gd": {
|
|
1374
1396
|
const splat = discImage.match(/([^/]+)\/?(.*)/);
|
|
@@ -1578,10 +1600,14 @@ async function gdLoad(cat, layout) {
|
|
|
1578
1600
|
|
|
1579
1601
|
for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "none";
|
|
1580
1602
|
(async () => {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1603
|
+
try {
|
|
1604
|
+
const available = await googleDrive.initialise();
|
|
1605
|
+
if (available) {
|
|
1606
|
+
for (const el of document.querySelectorAll(".if-drive-available")) el.style.display = "";
|
|
1607
|
+
await gdAuth(true);
|
|
1608
|
+
}
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
console.log(`Google Drive is unavailable: ${errorText(error)}`);
|
|
1585
1611
|
}
|
|
1586
1612
|
})();
|
|
1587
1613
|
const googleDriveModal = new bootstrap.Modal(googleDriveEl);
|
package/src/printer.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centronics printer attached to the user VIA: acknowledges each character and keeps the most
|
|
5
|
+
* recent output so it can be shown whenever a printer window is opened.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MaxBufferedChars = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export class Printer {
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} [handlers]
|
|
13
|
+
* @param {function(string): void} [handlers.onOutput] called with each character as it is printed
|
|
14
|
+
* @param {function(): void} [handlers.onFirstOutput] called once, when the machine first prints
|
|
15
|
+
*/
|
|
16
|
+
constructor({ onOutput = () => {}, onFirstOutput = () => {} } = {}) {
|
|
17
|
+
this._onOutput = onOutput;
|
|
18
|
+
this._onFirstOutput = onFirstOutput;
|
|
19
|
+
this._uservia = null;
|
|
20
|
+
this._olderText = "";
|
|
21
|
+
this._newerText = "";
|
|
22
|
+
this._hasPrinted = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
outputStrobe(level, output) {
|
|
26
|
+
if (!output || level) return;
|
|
27
|
+
|
|
28
|
+
const uservia = this._uservia;
|
|
29
|
+
// Ack the character by pulsing CA1 low.
|
|
30
|
+
uservia.setca1(false);
|
|
31
|
+
uservia.setca1(true);
|
|
32
|
+
this._append(String.fromCharCode(uservia.ora));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
attach(uservia) {
|
|
36
|
+
this._uservia = uservia;
|
|
37
|
+
// The ack line idles high, so that each ack is a falling edge.
|
|
38
|
+
uservia.setca1(true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The most recent output, at most MaxBufferedChars of it. */
|
|
42
|
+
get text() {
|
|
43
|
+
return (this._olderText + this._newerText).slice(-MaxBufferedChars);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_append(char) {
|
|
47
|
+
// Two chunks so the bound costs a trim when the text is read, not one per character.
|
|
48
|
+
this._newerText += char;
|
|
49
|
+
if (this._newerText.length >= MaxBufferedChars) {
|
|
50
|
+
this._olderText = this._newerText;
|
|
51
|
+
this._newerText = "";
|
|
52
|
+
}
|
|
53
|
+
if (!this._hasPrinted) {
|
|
54
|
+
this._hasPrinted = true;
|
|
55
|
+
this._onFirstOutput();
|
|
56
|
+
}
|
|
57
|
+
this._onOutput(char);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Minimal ZIP extractor
|
|
3
|
-
//
|
|
4
|
-
|
|
2
|
+
// Minimal ZIP extractor. Supports methods 0 (stored) and 8 (deflate); other
|
|
3
|
+
// methods (bzip2, lzma, etc.) will throw an error with the method number.
|
|
4
|
+
|
|
5
|
+
import { inflate as pakoInflate, inflateRaw as pakoInflateRaw, ungzip as pakoUngzip } from "pako";
|
|
5
6
|
|
|
6
7
|
const ZipLocalHeaderSig = 0x04034b50;
|
|
7
8
|
const ZipCentralDirSig = 0x02014b50;
|
|
@@ -35,45 +36,17 @@ function findEocd(buf) {
|
|
|
35
36
|
throw new Error("Not a ZIP file: EOCD not found");
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const ds = new DecompressionStream(format);
|
|
45
|
-
const writer = ds.writable.getWriter();
|
|
46
|
-
const reader = ds.readable.getReader();
|
|
47
|
-
const chunks = [];
|
|
48
|
-
const readPromise = (async () => {
|
|
49
|
-
for (;;) {
|
|
50
|
-
const { done, value } = await reader.read();
|
|
51
|
-
if (done) break;
|
|
52
|
-
chunks.push(value);
|
|
53
|
-
}
|
|
54
|
-
})();
|
|
55
|
-
const writePromise = (async () => {
|
|
56
|
-
await writer.write(data);
|
|
57
|
-
await writer.close();
|
|
58
|
-
})().catch(() => {
|
|
59
|
-
// Intentionally empty.
|
|
60
|
-
});
|
|
61
|
-
// Intentionally empty: Node's DecompressionStream rejects multiple
|
|
62
|
-
// promises on error (write, close, closed). The read side surfaces
|
|
63
|
-
// the same error with proper context — catching these just prevents
|
|
64
|
-
// unhandled rejections from the write-side promises.
|
|
65
|
-
writer.closed.catch(() => {});
|
|
66
|
-
await readPromise;
|
|
67
|
-
await writePromise;
|
|
68
|
-
if (chunks.length === 1) return chunks[0];
|
|
69
|
-
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
|
70
|
-
const out = new Uint8Array(totalLen);
|
|
71
|
-
let offset = 0;
|
|
72
|
-
for (const chunk of chunks) {
|
|
73
|
-
out.set(chunk, offset);
|
|
74
|
-
offset += chunk.length;
|
|
39
|
+
function inflateWith(inflater, data, context) {
|
|
40
|
+
if (!(data instanceof Uint8Array)) data = new Uint8Array(data);
|
|
41
|
+
try {
|
|
42
|
+
return inflater(data);
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw new Error(`${context}: ${cause.message || cause}`, { cause });
|
|
75
45
|
}
|
|
76
|
-
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inflate(data) {
|
|
49
|
+
return inflateWith(pakoInflate, data, "Unable to inflate");
|
|
77
50
|
}
|
|
78
51
|
|
|
79
52
|
// Extract all files from a ZIP archive. Returns {filename: Uint8Array}.
|
|
@@ -92,6 +65,7 @@ export async function unzip(buf) {
|
|
|
92
65
|
const flags = readU16(buf, pos + 8);
|
|
93
66
|
if (flags & 0x0001) throw new Error("Encrypted ZIP entries are not supported");
|
|
94
67
|
const method = readU16(buf, pos + 10);
|
|
68
|
+
const expectedCrc = readU32(buf, pos + 16);
|
|
95
69
|
const compressedSize = readU32(buf, pos + 20);
|
|
96
70
|
const nameLen = readU16(buf, pos + 28);
|
|
97
71
|
const extraLen = readU16(buf, pos + 30);
|
|
@@ -112,25 +86,25 @@ export async function unzip(buf) {
|
|
|
112
86
|
if (method === ZipMethodStored) {
|
|
113
87
|
files[name] = raw.slice();
|
|
114
88
|
} else if (method === ZipMethodDeflate) {
|
|
115
|
-
files[name] =
|
|
89
|
+
files[name] = inflateWith(pakoInflateRaw, raw, `Unable to inflate ZIP entry ${name}`);
|
|
116
90
|
} else {
|
|
117
91
|
throw new Error(`Unsupported ZIP compression method ${method} for ${name}`);
|
|
118
92
|
}
|
|
93
|
+
if (crc32(files[name]) >>> 0 !== expectedCrc) throw new Error(`Corrupt ZIP entry ${name}: CRC32 mismatch`);
|
|
119
94
|
}
|
|
120
95
|
return files;
|
|
121
96
|
}
|
|
122
97
|
|
|
123
98
|
// Standard CRC-32/ISO-HDLC.
|
|
99
|
+
const Crc32Table = new Uint32Array(256).map((_, index) => {
|
|
100
|
+
let crc = index;
|
|
101
|
+
for (let bit = 0; bit < 8; ++bit) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
102
|
+
return crc;
|
|
103
|
+
});
|
|
104
|
+
|
|
124
105
|
export function crc32(data) {
|
|
125
106
|
let crc = 0xffffffff;
|
|
126
|
-
for (let i = 0; i < data.length; ++i)
|
|
127
|
-
crc ^= data[i];
|
|
128
|
-
for (let j = 0; j < 8; ++j) {
|
|
129
|
-
const doEor = crc & 1;
|
|
130
|
-
crc = crc >>> 1;
|
|
131
|
-
if (doEor) crc ^= 0xedb88320;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
107
|
+
for (let i = 0; i < data.length; ++i) crc = (crc >>> 8) ^ Crc32Table[(crc ^ data[i]) & 0xff];
|
|
134
108
|
return ~crc;
|
|
135
109
|
}
|
|
136
110
|
|
|
@@ -1112,11 +1086,7 @@ export function readFloat32(data, offset) {
|
|
|
1112
1086
|
}
|
|
1113
1087
|
|
|
1114
1088
|
export async function ungzip(data) {
|
|
1115
|
-
|
|
1116
|
-
return await decompress(data, "gzip");
|
|
1117
|
-
} catch (cause) {
|
|
1118
|
-
throw new Error("Unable to ungzip: " + (cause.message || cause), { cause });
|
|
1119
|
-
}
|
|
1089
|
+
return inflateWith(pakoUngzip, data, "Unable to ungzip");
|
|
1120
1090
|
}
|
|
1121
1091
|
|
|
1122
1092
|
export class DataStream {
|
package/src/via.js
CHANGED
|
@@ -154,6 +154,11 @@ class Via {
|
|
|
154
154
|
}
|
|
155
155
|
this.updateIFR();
|
|
156
156
|
|
|
157
|
+
// The strobe is "data ready": the port settles before CA2 falls.
|
|
158
|
+
// http://archive.6502.org/datasheets/wdc_w65c22s_mar_2004.pdf figures 3-6 and 3-7.
|
|
159
|
+
this.ora = val;
|
|
160
|
+
this.recalculatePortAPins();
|
|
161
|
+
|
|
157
162
|
mode = this.pcr & 0x0e;
|
|
158
163
|
if (mode === 8) {
|
|
159
164
|
// Handshake mode
|
|
@@ -163,7 +168,8 @@ class Via {
|
|
|
163
168
|
this.setca2(false);
|
|
164
169
|
this.setca2(true);
|
|
165
170
|
}
|
|
166
|
-
|
|
171
|
+
break;
|
|
172
|
+
|
|
167
173
|
case ORAnh:
|
|
168
174
|
this.ora = val;
|
|
169
175
|
this.recalculatePortAPins();
|
package/src/web/audio-handler.js
CHANGED
|
@@ -4,6 +4,7 @@ import { RelayNoise, FakeRelayNoise } from "../relaynoise.js";
|
|
|
4
4
|
import { Music5000, FakeMusic5000 } from "../music5000.js";
|
|
5
5
|
import { createAudioContext } from "../audio-utils.js";
|
|
6
6
|
import { toggle, fadeIn, fadeOut } from "../dom-utils.js";
|
|
7
|
+
import { toast } from "./toast.js";
|
|
7
8
|
|
|
8
9
|
// Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit.
|
|
9
10
|
// See https://github.com/vitejs/vite/discussions/6459
|
|
@@ -15,7 +16,7 @@ export class AudioHandler {
|
|
|
15
16
|
this.cpuSpeed = cpuSpeed;
|
|
16
17
|
this.isAtom = isAtom;
|
|
17
18
|
this.warningNode = warningNode;
|
|
18
|
-
this.
|
|
19
|
+
this.noAudio = false;
|
|
19
20
|
toggle(this.warningNode, false);
|
|
20
21
|
this.stats = {};
|
|
21
22
|
if (statsNode) {
|
|
@@ -38,11 +39,11 @@ export class AudioHandler {
|
|
|
38
39
|
this.masterGain.connect(this.audioContext.destination);
|
|
39
40
|
this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
|
|
40
41
|
this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
|
|
41
|
-
this._setup(audioFilterFreq, audioFilterQ).
|
|
42
|
+
this._setup(audioFilterFreq, audioFilterQ).catch((error) => this._audioUnavailable(error));
|
|
42
43
|
} else {
|
|
43
44
|
if (this.audioContext && !this.audioContext.audioWorklet) {
|
|
44
45
|
this.audioContext = null;
|
|
45
|
-
this.
|
|
46
|
+
this.noAudio = true;
|
|
46
47
|
console.log("Unable to initialise audio: no audio worklet API");
|
|
47
48
|
const localhost = new URL(window.location);
|
|
48
49
|
localhost.hostname = "localhost";
|
|
@@ -66,12 +67,21 @@ export class AudioHandler {
|
|
|
66
67
|
this.audioContextM5000.onstatechange = () => this.checkStatus();
|
|
67
68
|
this.music5000 = new Music5000((buffer) => this._onBufferMusic5000(buffer));
|
|
68
69
|
|
|
69
|
-
this.audioContextM5000.audioWorklet
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
this.audioContextM5000.audioWorklet
|
|
71
|
+
.addModule(music5000WorkletUrl)
|
|
72
|
+
.then(() => {
|
|
73
|
+
this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
|
|
74
|
+
outputChannelCount: [2],
|
|
75
|
+
});
|
|
76
|
+
this._music5000workletnode.connect(this.audioContextM5000.destination);
|
|
77
|
+
})
|
|
78
|
+
.catch((error) => {
|
|
79
|
+
console.error("Unable to initialise Music 5000 audio", error);
|
|
80
|
+
toast(
|
|
81
|
+
`The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
|
|
82
|
+
{ title: "Music 5000", quietKey: "quietMusic5000Audio" },
|
|
83
|
+
);
|
|
72
84
|
});
|
|
73
|
-
this._music5000workletnode.connect(this.audioContextM5000.destination);
|
|
74
|
-
});
|
|
75
85
|
} else {
|
|
76
86
|
this.music5000 = new FakeMusic5000();
|
|
77
87
|
}
|
|
@@ -116,6 +126,13 @@ export class AudioHandler {
|
|
|
116
126
|
};
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
_audioUnavailable(error) {
|
|
130
|
+
console.error("Unable to initialise audio", error);
|
|
131
|
+
this.noAudio = true;
|
|
132
|
+
this.warningNode.textContent = `There will be no sound: the audio system could not be started (${error?.message ?? error}). Reloading the page may help.`;
|
|
133
|
+
fadeIn(this.warningNode);
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
_addStat(stat, info) {
|
|
120
137
|
const timeSeries = new this._TimeSeries();
|
|
121
138
|
this.stats[stat] = timeSeries;
|
|
@@ -146,7 +163,7 @@ export class AudioHandler {
|
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
checkStatus() {
|
|
149
|
-
if (this.
|
|
166
|
+
if (this.noAudio) return;
|
|
150
167
|
if (!this.audioContext && !this.audioContextM5000) return;
|
|
151
168
|
const suspended =
|
|
152
169
|
(this.audioContext && this.audioContext.state === "suspended") ||
|
package/src/web/toast.js
CHANGED
|
@@ -73,7 +73,11 @@ export function toast(message, { title = "", quietKey = "" } = {}) {
|
|
|
73
73
|
quiet.querySelector("input").addEventListener("change", (event) => remember(quietKey, event.target.checked));
|
|
74
74
|
|
|
75
75
|
toastContainer().appendChild(element);
|
|
76
|
-
element
|
|
76
|
+
// Bootstrap keys its instances by element, so one only removed stays held.
|
|
77
|
+
element.addEventListener("hidden.bs.toast", () => {
|
|
78
|
+
bootstrap.Toast.getInstance(element)?.dispose();
|
|
79
|
+
element.remove();
|
|
80
|
+
});
|
|
77
81
|
bootstrap.Toast.getOrCreateInstance(element).show();
|
|
78
82
|
return element;
|
|
79
83
|
}
|