jsbeeb 1.17.0 → 1.18.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 -0
- package/package.json +5 -3
- package/src/6502.js +11 -2
- package/src/bbcdiscs.js +31 -1
- package/src/bem-snapshot.js +3 -4
- package/src/canvas.js +71 -29
- 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/jsbeeb.css +23 -1
- package/src/machine-session.js +57 -0
- package/src/main.js +227 -105
- package/src/printer.js +59 -0
- package/src/teletext.js +12 -2
- package/src/teletext_adaptor.js +40 -5
- package/src/touchscreen.js +24 -3
- package/src/utils.js +44 -57
- package/src/via.js +32 -9
- package/src/web/audio-handler.js +26 -9
- package/src/web/toast.js +5 -1
- package/tests/test-machine.js +3 -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.
|
|
10
|
+
"version": "1.18.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"
|
|
@@ -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
|
},
|
|
@@ -105,10 +106,11 @@
|
|
|
105
106
|
"mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
|
|
106
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'",
|
|
107
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'",
|
|
108
|
-
"mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --out .bbcdiscs-mirror",
|
|
109
|
+
"mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --fsd .bbcdiscs-mirror/cache-fsd --out .bbcdiscs-mirror",
|
|
109
110
|
"mirror-bbcdiscs:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
|
|
110
111
|
"mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
|
|
111
|
-
"mirror-bbcdiscs:
|
|
112
|
+
"mirror-bbcdiscs:preflight": "node tools/mirror-bbcdiscs.js --preflight --out .bbcdiscs-mirror",
|
|
113
|
+
"mirror-bbcdiscs:upload": "npm-run-all mirror-bbcdiscs:preflight mirror-bbcdiscs:upload:*",
|
|
112
114
|
"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
115
|
"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'",
|
|
114
116
|
"electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
|
package/src/6502.js
CHANGED
|
@@ -709,6 +709,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
709
709
|
this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.relayNoise);
|
|
710
710
|
this.serial = new Serial(this.acia);
|
|
711
711
|
this.adconverter = new Adc(this.sysvia, this.scheduler);
|
|
712
|
+
this.touchScreen = new TouchScreen(this.scheduler, this.model.cyclesPerSecond);
|
|
712
713
|
this.soundChip.setScheduler(this.scheduler);
|
|
713
714
|
this.fdc = new this.model.Fdc(this, this.ddNoise, this.scheduler, this.debugFlags);
|
|
714
715
|
}
|
|
@@ -1240,7 +1241,9 @@ export class Cpu6502 extends Base6502 {
|
|
|
1240
1241
|
soundChip: this.soundChip.snapshotState(),
|
|
1241
1242
|
acia: this.acia.snapshotState(),
|
|
1242
1243
|
adc: this.adconverter.snapshotState(),
|
|
1244
|
+
touchScreen: this.touchScreen.snapshotState(),
|
|
1243
1245
|
fdc: this.fdc.snapshotState(),
|
|
1246
|
+
teletextAdaptor: this.teletextAdaptor ? this.teletextAdaptor.snapshotState() : undefined,
|
|
1244
1247
|
tube: this.hasTube ? this.tube.snapshotState({ includeRoms }) : undefined,
|
|
1245
1248
|
};
|
|
1246
1249
|
}
|
|
@@ -1297,6 +1300,11 @@ export class Cpu6502 extends Base6502 {
|
|
|
1297
1300
|
this.soundChip.restoreState(state.soundChip);
|
|
1298
1301
|
this.acia.restoreState(state.acia);
|
|
1299
1302
|
this.adconverter.restoreState(state.adc);
|
|
1303
|
+
if (this.teletextAdaptor && state.teletextAdaptor) this.teletextAdaptor.restoreState(state.teletextAdaptor);
|
|
1304
|
+
|
|
1305
|
+
// Touchscreen state, added without a version bump. Absent from an older snapshot, whose
|
|
1306
|
+
// touchscreen keeps its current state, unpolled.
|
|
1307
|
+
if (state.touchScreen) this.touchScreen.restoreState(state.touchScreen);
|
|
1300
1308
|
|
|
1301
1309
|
// FDC state (v2+). If absent (v1 snapshot), FDC keeps its current state.
|
|
1302
1310
|
if (state.fdc) {
|
|
@@ -1359,7 +1367,8 @@ export class Cpu6502 extends Base6502 {
|
|
|
1359
1367
|
// Override in subclasses for different peripheral sets.
|
|
1360
1368
|
// Only called on hard reset.
|
|
1361
1369
|
resetPeripherals() {
|
|
1362
|
-
if (this.config.printerPort)
|
|
1370
|
+
if (this.config.printerPort)
|
|
1371
|
+
this.uservia.ca2changecallback = (level, output) => this.config.printerPort.outputStrobe(level, output);
|
|
1363
1372
|
|
|
1364
1373
|
this.sysvia.reset();
|
|
1365
1374
|
this.uservia.reset();
|
|
@@ -1369,7 +1378,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
1369
1378
|
this.fdc.powerOnReset();
|
|
1370
1379
|
this.adconverter.reset();
|
|
1371
1380
|
|
|
1372
|
-
this.touchScreen
|
|
1381
|
+
this.touchScreen.reset();
|
|
1373
1382
|
if (this.econet) this.filestore = new Filestore(this, this.econet);
|
|
1374
1383
|
}
|
|
1375
1384
|
|
package/src/bbcdiscs.js
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const mirrorBase = "https://bbc.xania.org/archive/bbcdiscs";
|
|
4
4
|
|
|
5
|
+
/** How a disc came to be an image, which is the difference between the archives it came from. */
|
|
6
|
+
export const Provenance = {
|
|
7
|
+
/** Read off the disc itself, by flux capture. */
|
|
8
|
+
Captured: "captured",
|
|
9
|
+
/** Rebuilt from a sector dump, so the surface around the data is inferred. */
|
|
10
|
+
Reconstructed: "reconstructed",
|
|
11
|
+
};
|
|
12
|
+
|
|
5
13
|
// Numeric so a "Disc 2" would sort before a "Disc 10" rather than after it,
|
|
6
14
|
// and case-insensitive so a lower-cased title stays with its neighbours.
|
|
7
15
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
@@ -34,6 +42,25 @@ export function describe(file) {
|
|
|
34
42
|
};
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
/** The provenances a catalogue actually holds, so a source added later needs no code here. */
|
|
46
|
+
export const provenancesIn = (catalogue) => [...new Set(catalogue.map((file) => file.provenance))].sort();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a disc belongs in the picker as it is currently filtered.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} file manifest entry
|
|
52
|
+
* @param {string} filter lower cased text to look for
|
|
53
|
+
* @param {?Set<string>} shown provenances to include, or null for all of them
|
|
54
|
+
*/
|
|
55
|
+
export function matches(file, filter, shown) {
|
|
56
|
+
if (shown && !shown.has(file.provenance)) return false;
|
|
57
|
+
if (!filter) return true;
|
|
58
|
+
// What the row says the disc is, rather than everything the row renders:
|
|
59
|
+
// the provenance is a word the tickboxes control, not one to search for.
|
|
60
|
+
const { title, publisher, detail } = describe(file);
|
|
61
|
+
return `${title} ${publisher} ${detail}`.toLowerCase().includes(filter);
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
export class BbcDiscArchive {
|
|
38
65
|
/** @param {string} [baseUrl] where the mirror lives, to point at a test prefix */
|
|
39
66
|
constructor(onStart, onCat, onError, baseUrl = mirrorBase) {
|
|
@@ -55,7 +82,10 @@ export class BbcDiscArchive {
|
|
|
55
82
|
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
|
|
56
83
|
const data = await response.json();
|
|
57
84
|
if (!Array.isArray(data?.files)) throw new Error("Invalid manifest: missing files array");
|
|
58
|
-
this._catalogue =
|
|
85
|
+
this._catalogue = data.files
|
|
86
|
+
// The captured discs were published before provenance was recorded.
|
|
87
|
+
.map((file) => ({ ...file, provenance: file.provenance ?? Provenance.Captured }))
|
|
88
|
+
.sort(byTitle);
|
|
59
89
|
this._loaded = true;
|
|
60
90
|
} catch (error) {
|
|
61
91
|
console.error("Failed to fetch HFE archive catalogue:", error);
|
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
|
@@ -38,6 +38,11 @@ export class Canvas {
|
|
|
38
38
|
/** Nothing to release: the 2D context owns no objects of ours. */
|
|
39
39
|
dispose() {}
|
|
40
40
|
|
|
41
|
+
setFilter(filterClass) {
|
|
42
|
+
if (filterClass !== PassthroughFilter)
|
|
43
|
+
throw new Error(`${filterClass.getDisplayConfig().name} needs WebGL, which is not in use here`);
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
paint(minx, miny, maxx, maxy, _frame) {
|
|
42
47
|
const width = maxx - minx;
|
|
43
48
|
const height = maxy - miny;
|
|
@@ -78,23 +83,14 @@ export class GlCanvas {
|
|
|
78
83
|
|
|
79
84
|
checkedGl.depthMask(false);
|
|
80
85
|
|
|
81
|
-
this.filter = new filterClass(checkedGl);
|
|
82
|
-
const program = this.filter.program;
|
|
83
|
-
checkedGl.useProgram(program);
|
|
84
|
-
|
|
85
|
-
// Filters that pick their own samples want the texels they asked for,
|
|
86
|
-
// not a hardware blend of the ones either side.
|
|
87
|
-
const sampling = filterClass.getDisplayConfig().nearestSampling ? checkedGl.NEAREST : checkedGl.LINEAR;
|
|
88
|
-
|
|
89
86
|
this.fb8 = new Uint8Array(width * height * 4);
|
|
90
87
|
this.fb32 = new Uint32Array(this.fb8.buffer);
|
|
91
88
|
this.texture = checkedGl.createTexture();
|
|
89
|
+
checkedGl.activeTexture(checkedGl.TEXTURE0);
|
|
92
90
|
checkedGl.bindTexture(checkedGl.TEXTURE_2D, this.texture);
|
|
93
91
|
checkedGl.pixelStorei(checkedGl.UNPACK_ALIGNMENT, 4);
|
|
94
92
|
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_S, checkedGl.CLAMP_TO_EDGE);
|
|
95
93
|
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_T, checkedGl.CLAMP_TO_EDGE);
|
|
96
|
-
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER, sampling);
|
|
97
|
-
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER, sampling);
|
|
98
94
|
checkedGl.texImage2D(
|
|
99
95
|
checkedGl.TEXTURE_2D,
|
|
100
96
|
0,
|
|
@@ -106,44 +102,72 @@ export class GlCanvas {
|
|
|
106
102
|
checkedGl.UNSIGNED_BYTE,
|
|
107
103
|
this.fb8,
|
|
108
104
|
);
|
|
109
|
-
checkedGl.bindTexture(checkedGl.TEXTURE_2D, null);
|
|
110
105
|
|
|
111
|
-
const vertexPositionAttrLoc = checkedGl.getAttribLocation(program, "pos");
|
|
112
|
-
checkedGl.enableVertexAttribArray(vertexPositionAttrLoc);
|
|
113
106
|
this.vertexPositionBuffer = checkedGl.createBuffer();
|
|
114
107
|
checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.vertexPositionBuffer);
|
|
115
108
|
checkedGl.bufferData(checkedGl.ARRAY_BUFFER, new Float32Array([0, 0, 0, 1, 1, 0, 1, 1]), checkedGl.STATIC_DRAW);
|
|
116
|
-
checkedGl.vertexAttribPointer(vertexPositionAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
|
|
117
|
-
|
|
118
|
-
const uvAttrLoc = checkedGl.getAttribLocation(program, "uvIn");
|
|
119
|
-
checkedGl.enableVertexAttribArray(uvAttrLoc);
|
|
120
109
|
this.uvBuffer = checkedGl.createBuffer();
|
|
121
|
-
checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.uvBuffer);
|
|
122
|
-
checkedGl.vertexAttribPointer(uvAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
|
|
123
|
-
|
|
124
|
-
checkedGl.activeTexture(gl.TEXTURE0);
|
|
125
|
-
checkedGl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
126
110
|
|
|
127
111
|
this.checkedGl = checkedGl;
|
|
112
|
+
this.filter = null;
|
|
113
|
+
this.attribLocations = [];
|
|
128
114
|
this.viewportWidth = this.viewportHeight = 0;
|
|
129
115
|
this.uvFloatArray = new Float32Array(8);
|
|
130
116
|
this.lastExtent = {};
|
|
131
117
|
|
|
118
|
+
try {
|
|
119
|
+
this.setFilter(filterClass);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
this.dispose();
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
|
|
132
125
|
console.log("GL Canvas set up");
|
|
133
126
|
}
|
|
134
127
|
|
|
135
128
|
/**
|
|
136
|
-
*
|
|
129
|
+
* Draw with `filterClass` from here on, keeping the framebuffer texture and
|
|
130
|
+
* the vertex buffers: only the program, the texture sampling mode and the
|
|
131
|
+
* attribute locations differ between filters.
|
|
137
132
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
133
|
+
* The new filter is built before the old one is disposed, so a filter that
|
|
134
|
+
* will not build leaves the canvas drawing as it was.
|
|
135
|
+
*/
|
|
136
|
+
setFilter(filterClass) {
|
|
137
|
+
const gl = this.checkedGl;
|
|
138
|
+
const filter = new filterClass(gl);
|
|
139
|
+
this.filter?.dispose();
|
|
140
|
+
this.filter = filter;
|
|
141
|
+
gl.useProgram(filter.program);
|
|
142
|
+
|
|
143
|
+
// Filters that pick their own samples want the texels they asked for,
|
|
144
|
+
// not a hardware blend of the ones either side.
|
|
145
|
+
const sampling = filterClass.getDisplayConfig().nearestSampling ? gl.NEAREST : gl.LINEAR;
|
|
146
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
147
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
148
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, sampling);
|
|
149
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, sampling);
|
|
150
|
+
|
|
151
|
+
const bindAttribute = (name, buffer) => {
|
|
152
|
+
const location = gl.getAttribLocation(filter.program, name);
|
|
153
|
+
gl.enableVertexAttribArray(location);
|
|
154
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
155
|
+
gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0);
|
|
156
|
+
return location;
|
|
157
|
+
};
|
|
158
|
+
for (const location of this.attribLocations) gl.disableVertexAttribArray(location);
|
|
159
|
+
this.attribLocations = [bindAttribute("pos", this.vertexPositionBuffer), bindAttribute("uvIn", this.uvBuffer)];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Release the GL objects this canvas owns. Nothing else will: a canvas
|
|
164
|
+
* element hands out one WebGL context for its lifetime, so anything created
|
|
165
|
+
* through that context stays resident however many wrappers come and go.
|
|
143
166
|
*/
|
|
144
167
|
dispose() {
|
|
145
168
|
const gl = this.checkedGl;
|
|
146
|
-
this.filter
|
|
169
|
+
this.filter?.dispose();
|
|
170
|
+
this.filter = null;
|
|
147
171
|
gl.deleteTexture(this.texture);
|
|
148
172
|
gl.deleteBuffer(this.vertexPositionBuffer);
|
|
149
173
|
gl.deleteBuffer(this.uvBuffer);
|
|
@@ -218,6 +242,24 @@ function fellBackBecause(canvas, reason) {
|
|
|
218
242
|
return canvas;
|
|
219
243
|
}
|
|
220
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Draw with `filterClass`, or with the unfiltered display if it will not build,
|
|
247
|
+
* in which case `fallbackReason` says why.
|
|
248
|
+
*/
|
|
249
|
+
export function useBestFilter(canvas, filterClass) {
|
|
250
|
+
let reason;
|
|
251
|
+
try {
|
|
252
|
+
canvas.setFilter(filterClass);
|
|
253
|
+
return fellBackBecause(canvas, undefined);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.log(`Unable to use ${filterClass.getDisplayConfig().name}: ${e}`);
|
|
256
|
+
if (filterClass === PassthroughFilter) throw e;
|
|
257
|
+
reason = e?.message ?? e;
|
|
258
|
+
}
|
|
259
|
+
canvas.setFilter(PassthroughFilter);
|
|
260
|
+
return fellBackBecause(canvas, reason);
|
|
261
|
+
}
|
|
262
|
+
|
|
221
263
|
export function bestCanvas(canvas, filterClass) {
|
|
222
264
|
let reason;
|
|
223
265
|
try {
|
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/jsbeeb.css
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
body {
|
|
2
2
|
overflow: hidden;
|
|
3
|
+
visibility: visible !important; /* see index.html */
|
|
3
4
|
}
|
|
4
5
|
#outer {
|
|
5
6
|
display: block;
|
|
@@ -171,7 +172,7 @@ th {
|
|
|
171
172
|
|
|
172
173
|
#hfe-list li a {
|
|
173
174
|
display: grid;
|
|
174
|
-
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr);
|
|
175
|
+
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr) 7em;
|
|
175
176
|
gap: 0 1rem;
|
|
176
177
|
align-items: baseline;
|
|
177
178
|
padding: 3px 6px;
|
|
@@ -198,6 +199,22 @@ th {
|
|
|
198
199
|
font-family: monospace;
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
#hfe-list .provenance {
|
|
203
|
+
font-size: 0.8em;
|
|
204
|
+
opacity: 0.6;
|
|
205
|
+
font-style: italic;
|
|
206
|
+
text-align: right;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.hfe-provenance label {
|
|
210
|
+
margin-left: 1.5rem;
|
|
211
|
+
opacity: 0.8;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.hfe-provenance input {
|
|
215
|
+
margin-right: 0.3em;
|
|
216
|
+
}
|
|
217
|
+
|
|
201
218
|
.hfe-credit {
|
|
202
219
|
font-size: 0.9em;
|
|
203
220
|
opacity: 0.75;
|
|
@@ -208,6 +225,11 @@ th {
|
|
|
208
225
|
grid-template-columns: minmax(0, 1fr);
|
|
209
226
|
padding-bottom: 6px;
|
|
210
227
|
}
|
|
228
|
+
|
|
229
|
+
/* Stacked into one column, so there is no column end to sit against. */
|
|
230
|
+
#hfe-list .provenance {
|
|
231
|
+
text-align: left;
|
|
232
|
+
}
|
|
211
233
|
}
|
|
212
234
|
|
|
213
235
|
div.filter {
|
package/src/machine-session.js
CHANGED
|
@@ -25,6 +25,9 @@ import sharp from "sharp";
|
|
|
25
25
|
const FB_WIDTH = 1024;
|
|
26
26
|
const FB_HEIGHT = 625;
|
|
27
27
|
|
|
28
|
+
// Five times a frame, so only a machine that has stopped painting hits it.
|
|
29
|
+
const BackstopSecondsPerFrame = 0.1;
|
|
30
|
+
|
|
28
31
|
export class MachineSession {
|
|
29
32
|
/**
|
|
30
33
|
* @param {string} modelName - e.g. "B-DFS1.2", "Master"
|
|
@@ -46,6 +49,8 @@ export class MachineSession {
|
|
|
46
49
|
this._completeFb8 = new Uint8Array(FB_WIDTH * FB_HEIGHT * 4);
|
|
47
50
|
this._lastPaint = { minx: 0, miny: 0, maxx: FB_WIDTH, maxy: FB_HEIGHT };
|
|
48
51
|
this._frameDirty = false;
|
|
52
|
+
this._frameCount = 0;
|
|
53
|
+
this._stopAtFrame = Infinity;
|
|
49
54
|
|
|
50
55
|
// Create a real Video instance so we get pixel output
|
|
51
56
|
const modelObj = findModel(modelName);
|
|
@@ -59,6 +64,8 @@ export class MachineSession {
|
|
|
59
64
|
// Snapshot the complete frame now, before clearPaintBuffer() wipes _fb32.
|
|
60
65
|
// This mirrors what the browser does: paint_ext fires → canvas updated → fb32 cleared.
|
|
61
66
|
this._completeFb8.set(this._fb8);
|
|
67
|
+
this._frameCount++;
|
|
68
|
+
if (this._frameCount >= this._stopAtFrame) this._machine.processor.stop();
|
|
62
69
|
},
|
|
63
70
|
{ isAtom: modelObj.isAtom },
|
|
64
71
|
);
|
|
@@ -308,6 +315,56 @@ export class MachineSession {
|
|
|
308
315
|
await this._machine.runFor(cycles);
|
|
309
316
|
}
|
|
310
317
|
|
|
318
|
+
/** Emulated cycles since power-on, undoing the per-second rebasing execute() applies */
|
|
319
|
+
get elapsedCycles() {
|
|
320
|
+
const cpu = this._machine.processor;
|
|
321
|
+
return cpu.cycleSeconds * cpu.model.cyclesPerSecond + cpu.currentCycles;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Run until `count` more frames have been painted, stopping on the paint
|
|
326
|
+
* itself. A frame is 40000 cycles interlaced, 39936 not, and whatever a
|
|
327
|
+
* program driving the CRTC makes it, so stepping by cycles instead walks
|
|
328
|
+
* the sample point through the guest's frame.
|
|
329
|
+
*
|
|
330
|
+
* `completed` is false if a breakpoint fired, or the backstop ran out
|
|
331
|
+
* first.
|
|
332
|
+
*
|
|
333
|
+
* @param {number} [count=1] frames to advance
|
|
334
|
+
* @param {Object} [opts]
|
|
335
|
+
* @param {number} [opts.maxCycles] how long to wait on a machine that is not painting
|
|
336
|
+
* @returns {Promise<{framesRun: number, cyclesRun: number, completed: boolean}>}
|
|
337
|
+
*/
|
|
338
|
+
async runFrames(count = 1, { maxCycles } = {}) {
|
|
339
|
+
const cpu = this._machine.processor;
|
|
340
|
+
const backstop = maxCycles ?? count * BackstopSecondsPerFrame * cpu.model.cyclesPerSecond;
|
|
341
|
+
const startFrame = this._frameCount;
|
|
342
|
+
const startCycles = this.elapsedCycles;
|
|
343
|
+
// execute() adds each request to a running targetCycles, so budget left
|
|
344
|
+
// unspent by an early stop would silently lengthen the caller's next run.
|
|
345
|
+
const unspentBefore = cpu.targetCycles - cpu.currentCycles;
|
|
346
|
+
|
|
347
|
+
this._stopAtFrame = startFrame + count;
|
|
348
|
+
try {
|
|
349
|
+
await this._machine.runFor(backstop);
|
|
350
|
+
} finally {
|
|
351
|
+
this._stopAtFrame = Infinity;
|
|
352
|
+
cpu.targetCycles = cpu.currentCycles + unspentBefore;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const framesRun = this._frameCount - startFrame;
|
|
356
|
+
return {
|
|
357
|
+
framesRun,
|
|
358
|
+
cyclesRun: this.elapsedCycles - startCycles,
|
|
359
|
+
completed: framesRun >= count,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Frames painted since the session was created; a hard reset does not zero it */
|
|
364
|
+
get frameCount() {
|
|
365
|
+
return this._frameCount;
|
|
366
|
+
}
|
|
367
|
+
|
|
311
368
|
/**
|
|
312
369
|
* Run until PC reaches targetAddr (like a breakpoint), or timeout.
|
|
313
370
|
*/
|