jsbeeb 1.17.1 → 1.19.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/package.json +4 -3
- package/src/6502.js +9 -1
- package/src/bbcdiscs.js +31 -1
- package/src/canvas.js +95 -37
- package/src/jsbeeb.css +23 -1
- package/src/machine-session.js +57 -0
- package/src/main.js +171 -69
- package/src/snapshot-helpers.js +0 -1
- package/src/teletext.js +21 -13
- package/src/teletext_adaptor.js +40 -5
- package/src/touchscreen.js +24 -3
- package/src/utils.js +19 -2
- package/src/via.js +25 -8
- package/src/web/audio-renderer.js +13 -8
- package/tests/test-machine.js +6 -8
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.19.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"
|
|
@@ -106,10 +106,11 @@
|
|
|
106
106
|
"mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
|
|
107
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'",
|
|
108
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",
|
|
109
|
+
"mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --fsd .bbcdiscs-mirror/cache-fsd --out .bbcdiscs-mirror",
|
|
110
110
|
"mirror-bbcdiscs:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
|
|
111
111
|
"mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
|
|
112
|
-
"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:*",
|
|
113
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'",
|
|
114
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'",
|
|
115
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) {
|
|
@@ -1370,7 +1378,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
1370
1378
|
this.fdc.powerOnReset();
|
|
1371
1379
|
this.adconverter.reset();
|
|
1372
1380
|
|
|
1373
|
-
this.touchScreen
|
|
1381
|
+
this.touchScreen.reset();
|
|
1374
1382
|
if (this.econet) this.filestore = new Filestore(this, this.econet);
|
|
1375
1383
|
}
|
|
1376
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/canvas.js
CHANGED
|
@@ -14,15 +14,27 @@ export function getFilterForMode(mode) {
|
|
|
14
14
|
return DISPLAY_MODE_FILTERS[mode] || DISPLAY_MODE_FILTERS.rgb;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// The hint asks the browser to skip the renderer compositor queue and hand the buffer straight to
|
|
18
|
+
// the display controller, saving a frame or so of output latency. It is only a hint, so read back
|
|
19
|
+
// what we actually got.
|
|
20
|
+
// https://developer.chrome.com/blog/desynchronized
|
|
21
|
+
function reportDesynchronized(ctx, asked) {
|
|
22
|
+
// A lost context returns null here rather than an attributes object.
|
|
23
|
+
const honoured = ctx.getContextAttributes?.()?.desynchronized ?? false;
|
|
24
|
+
if (!asked) console.log("Low latency canvas turned off");
|
|
25
|
+
else console.log(`Low latency canvas ${honoured ? "in use" : "not available"}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
export class Canvas {
|
|
18
29
|
/** The 2D canvas draws the framebuffer as-is, which is what this filter is. */
|
|
19
30
|
get filterClass() {
|
|
20
31
|
return PassthroughFilter;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
|
-
constructor(canvas) {
|
|
24
|
-
this.ctx = canvas.getContext("2d", { alpha: false });
|
|
34
|
+
constructor(canvas, lowLatency = true) {
|
|
35
|
+
this.ctx = canvas.getContext("2d", { alpha: false, desynchronized: lowLatency });
|
|
25
36
|
if (this.ctx === null) throw new Error("Unable to get a 2D context");
|
|
37
|
+
reportDesynchronized(this.ctx, lowLatency);
|
|
26
38
|
this.ctx.fillStyle = "black";
|
|
27
39
|
this.ctx.fillRect(0, 0, 1024, 625);
|
|
28
40
|
this.backBuffer = window.document.createElement("canvas");
|
|
@@ -38,6 +50,11 @@ export class Canvas {
|
|
|
38
50
|
/** Nothing to release: the 2D context owns no objects of ours. */
|
|
39
51
|
dispose() {}
|
|
40
52
|
|
|
53
|
+
setFilter(filterClass) {
|
|
54
|
+
if (filterClass !== PassthroughFilter)
|
|
55
|
+
throw new Error(`${filterClass.getDisplayConfig().name} needs WebGL, which is not in use here`);
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
paint(minx, miny, maxx, maxy, _frame) {
|
|
42
59
|
const width = maxx - minx;
|
|
43
60
|
const height = maxy - miny;
|
|
@@ -55,7 +72,7 @@ export class GlCanvas {
|
|
|
55
72
|
return this.filter.constructor;
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
constructor(canvas, filterClass) {
|
|
75
|
+
constructor(canvas, filterClass, lowLatency = true) {
|
|
59
76
|
// failIfMajorPerformanceCaveat prevents the use of CPU based WebGL
|
|
60
77
|
// rendering, which is much worse than simply using a 2D canvas for
|
|
61
78
|
// rendering.
|
|
@@ -63,38 +80,33 @@ export class GlCanvas {
|
|
|
63
80
|
alpha: false,
|
|
64
81
|
antialias: false,
|
|
65
82
|
depth: false,
|
|
66
|
-
|
|
83
|
+
// A desynchronized context can be scanned out while it is cleared but not yet redrawn,
|
|
84
|
+
// which flickers unless the buffer is preserved between frames.
|
|
85
|
+
preserveDrawingBuffer: lowLatency,
|
|
67
86
|
stencil: false,
|
|
68
87
|
failIfMajorPerformanceCaveat: true,
|
|
88
|
+
desynchronized: lowLatency,
|
|
69
89
|
};
|
|
70
90
|
const gl = canvas.getContext("webgl", glAttrs) || canvas.getContext("experimental-webgl", glAttrs);
|
|
71
91
|
this.gl = gl;
|
|
72
92
|
if (!gl) {
|
|
73
93
|
throw new Error("Unable to create a GL context");
|
|
74
94
|
}
|
|
95
|
+
reportDesynchronized(gl, lowLatency);
|
|
75
96
|
const checkedGl = webglDebug.makeDebugContext(gl, function (err, funcName) {
|
|
76
97
|
throw new Error("Problem creating GL context: " + webglDebug.glEnumToString(err) + " in " + funcName);
|
|
77
98
|
});
|
|
78
99
|
|
|
79
100
|
checkedGl.depthMask(false);
|
|
80
101
|
|
|
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
102
|
this.fb8 = new Uint8Array(width * height * 4);
|
|
90
103
|
this.fb32 = new Uint32Array(this.fb8.buffer);
|
|
91
104
|
this.texture = checkedGl.createTexture();
|
|
105
|
+
checkedGl.activeTexture(checkedGl.TEXTURE0);
|
|
92
106
|
checkedGl.bindTexture(checkedGl.TEXTURE_2D, this.texture);
|
|
93
107
|
checkedGl.pixelStorei(checkedGl.UNPACK_ALIGNMENT, 4);
|
|
94
108
|
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_S, checkedGl.CLAMP_TO_EDGE);
|
|
95
109
|
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
110
|
checkedGl.texImage2D(
|
|
99
111
|
checkedGl.TEXTURE_2D,
|
|
100
112
|
0,
|
|
@@ -106,44 +118,72 @@ export class GlCanvas {
|
|
|
106
118
|
checkedGl.UNSIGNED_BYTE,
|
|
107
119
|
this.fb8,
|
|
108
120
|
);
|
|
109
|
-
checkedGl.bindTexture(checkedGl.TEXTURE_2D, null);
|
|
110
121
|
|
|
111
|
-
const vertexPositionAttrLoc = checkedGl.getAttribLocation(program, "pos");
|
|
112
|
-
checkedGl.enableVertexAttribArray(vertexPositionAttrLoc);
|
|
113
122
|
this.vertexPositionBuffer = checkedGl.createBuffer();
|
|
114
123
|
checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.vertexPositionBuffer);
|
|
115
124
|
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
125
|
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
126
|
|
|
127
127
|
this.checkedGl = checkedGl;
|
|
128
|
+
this.filter = null;
|
|
129
|
+
this.attribLocations = [];
|
|
128
130
|
this.viewportWidth = this.viewportHeight = 0;
|
|
129
131
|
this.uvFloatArray = new Float32Array(8);
|
|
130
132
|
this.lastExtent = {};
|
|
131
133
|
|
|
134
|
+
try {
|
|
135
|
+
this.setFilter(filterClass);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
this.dispose();
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
|
|
132
141
|
console.log("GL Canvas set up");
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
/**
|
|
136
|
-
*
|
|
145
|
+
* Draw with `filterClass` from here on, keeping the framebuffer texture and
|
|
146
|
+
* the vertex buffers: only the program, the texture sampling mode and the
|
|
147
|
+
* attribute locations differ between filters.
|
|
137
148
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
* The new filter is built before the old one is disposed, so a filter that
|
|
150
|
+
* will not build leaves the canvas drawing as it was.
|
|
151
|
+
*/
|
|
152
|
+
setFilter(filterClass) {
|
|
153
|
+
const gl = this.checkedGl;
|
|
154
|
+
const filter = new filterClass(gl);
|
|
155
|
+
this.filter?.dispose();
|
|
156
|
+
this.filter = filter;
|
|
157
|
+
gl.useProgram(filter.program);
|
|
158
|
+
|
|
159
|
+
// Filters that pick their own samples want the texels they asked for,
|
|
160
|
+
// not a hardware blend of the ones either side.
|
|
161
|
+
const sampling = filterClass.getDisplayConfig().nearestSampling ? gl.NEAREST : gl.LINEAR;
|
|
162
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
163
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
164
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, sampling);
|
|
165
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, sampling);
|
|
166
|
+
|
|
167
|
+
const bindAttribute = (name, buffer) => {
|
|
168
|
+
const location = gl.getAttribLocation(filter.program, name);
|
|
169
|
+
gl.enableVertexAttribArray(location);
|
|
170
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
171
|
+
gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0);
|
|
172
|
+
return location;
|
|
173
|
+
};
|
|
174
|
+
for (const location of this.attribLocations) gl.disableVertexAttribArray(location);
|
|
175
|
+
this.attribLocations = [bindAttribute("pos", this.vertexPositionBuffer), bindAttribute("uvIn", this.uvBuffer)];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Release the GL objects this canvas owns. Nothing else will: a canvas
|
|
180
|
+
* element hands out one WebGL context for its lifetime, so anything created
|
|
181
|
+
* through that context stays resident however many wrappers come and go.
|
|
143
182
|
*/
|
|
144
183
|
dispose() {
|
|
145
184
|
const gl = this.checkedGl;
|
|
146
|
-
this.filter
|
|
185
|
+
this.filter?.dispose();
|
|
186
|
+
this.filter = null;
|
|
147
187
|
gl.deleteTexture(this.texture);
|
|
148
188
|
gl.deleteBuffer(this.vertexPositionBuffer);
|
|
149
189
|
gl.deleteBuffer(this.uvBuffer);
|
|
@@ -218,10 +258,28 @@ function fellBackBecause(canvas, reason) {
|
|
|
218
258
|
return canvas;
|
|
219
259
|
}
|
|
220
260
|
|
|
221
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Draw with `filterClass`, or with the unfiltered display if it will not build,
|
|
263
|
+
* in which case `fallbackReason` says why.
|
|
264
|
+
*/
|
|
265
|
+
export function useBestFilter(canvas, filterClass) {
|
|
266
|
+
let reason;
|
|
267
|
+
try {
|
|
268
|
+
canvas.setFilter(filterClass);
|
|
269
|
+
return fellBackBecause(canvas, undefined);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
console.log(`Unable to use ${filterClass.getDisplayConfig().name}: ${e}`);
|
|
272
|
+
if (filterClass === PassthroughFilter) throw e;
|
|
273
|
+
reason = e?.message ?? e;
|
|
274
|
+
}
|
|
275
|
+
canvas.setFilter(PassthroughFilter);
|
|
276
|
+
return fellBackBecause(canvas, reason);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function bestCanvas(canvas, filterClass, lowLatency = true) {
|
|
222
280
|
let reason;
|
|
223
281
|
try {
|
|
224
|
-
return new GlCanvas(canvas, filterClass);
|
|
282
|
+
return new GlCanvas(canvas, filterClass, lowLatency);
|
|
225
283
|
} catch (e) {
|
|
226
284
|
// Either WebGL is unavailable or this particular filter declined it.
|
|
227
285
|
reason = e?.message ?? e;
|
|
@@ -233,11 +291,11 @@ export function bestCanvas(canvas, filterClass) {
|
|
|
233
291
|
// 2D fallback below would throw and take the emulator with it.
|
|
234
292
|
if (filterClass !== PassthroughFilter) {
|
|
235
293
|
try {
|
|
236
|
-
return fellBackBecause(new GlCanvas(canvas, PassthroughFilter), reason);
|
|
294
|
+
return fellBackBecause(new GlCanvas(canvas, PassthroughFilter, lowLatency), reason);
|
|
237
295
|
} catch (e) {
|
|
238
296
|
console.log("Unable to fall back to the passthrough filter: " + e);
|
|
239
297
|
}
|
|
240
298
|
}
|
|
241
299
|
|
|
242
|
-
return fellBackBecause(new Canvas(canvas), reason);
|
|
300
|
+
return fellBackBecause(new Canvas(canvas, lowLatency), reason);
|
|
243
301
|
}
|
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
|
*/
|