jsbeeb 1.18.0 → 1.19.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 +4 -4
- package/package.json +1 -1
- package/src/canvas.js +24 -8
- package/src/main.js +7 -1
- package/src/snapshot-helpers.js +0 -1
- package/src/teletext.js +10 -12
- package/src/url-params.js +30 -12
- package/src/web/audio-renderer.js +13 -8
- package/tests/test-machine.js +3 -7
package/README.md
CHANGED
|
@@ -51,15 +51,15 @@ KEY.<host key>=<BBC key>
|
|
|
51
51
|
Add one for each key you want to change. For example, Superior Software's Space Invaders fires with `COPY`; this makes
|
|
52
52
|
`Enter` fire instead:
|
|
53
53
|
|
|
54
|
-
[`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY`](https://bbc.xania.org/?disc1=sth
|
|
54
|
+
[`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY`](https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY)
|
|
55
55
|
|
|
56
56
|
Superior's Frogger uses `A`/`Z`/`DELETE`/`COPY` to move; this puts it on the arrow keys:
|
|
57
57
|
|
|
58
|
-
[`https://bbc.xania.org/?disc1=sth:Superior/Frogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY`](https://bbc.xania.org/?disc1=sth
|
|
58
|
+
[`https://bbc.xania.org/?disc1=sth:Superior/Frogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY`](https://bbc.xania.org/?disc1=sth:Superior/Frogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY)
|
|
59
59
|
|
|
60
60
|
And Superior's Hunchback steers with `CAPS LOCK` and `CTRL`, which the arrow keys can stand in for:
|
|
61
61
|
|
|
62
|
-
[`https://bbc.xania.org/?disc1=sth:Superior/Hunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL`](https://bbc.xania.org/?disc1=sth
|
|
62
|
+
[`https://bbc.xania.org/?disc1=sth:Superior/Hunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL`](https://bbc.xania.org/?disc1=sth:Superior/Hunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL)
|
|
63
63
|
|
|
64
64
|
The **host key** names are jsbeeb's names for the keys on your own keyboard. Most are what you'd expect, but note:
|
|
65
65
|
|
|
@@ -141,7 +141,7 @@ GP.<gamepad control>=<BBC key>
|
|
|
141
141
|
By default the D-pad presses the "Snapper" keys (`Z`, `X`, `:`, `/`), the `A` button presses `RETURN` and `Start`
|
|
142
142
|
presses `SPACE`. To play Superior's Space Invaders on a pad, where `COPY` fires:
|
|
143
143
|
|
|
144
|
-
[`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY`](https://bbc.xania.org/?disc1=sth
|
|
144
|
+
[`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY`](https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY)
|
|
145
145
|
|
|
146
146
|
The gamepad control names are:
|
|
147
147
|
|
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.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"
|
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");
|
|
@@ -60,7 +72,7 @@ export class GlCanvas {
|
|
|
60
72
|
return this.filter.constructor;
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
constructor(canvas, filterClass) {
|
|
75
|
+
constructor(canvas, filterClass, lowLatency = true) {
|
|
64
76
|
// failIfMajorPerformanceCaveat prevents the use of CPU based WebGL
|
|
65
77
|
// rendering, which is much worse than simply using a 2D canvas for
|
|
66
78
|
// rendering.
|
|
@@ -68,15 +80,19 @@ export class GlCanvas {
|
|
|
68
80
|
alpha: false,
|
|
69
81
|
antialias: false,
|
|
70
82
|
depth: false,
|
|
71
|
-
|
|
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,
|
|
72
86
|
stencil: false,
|
|
73
87
|
failIfMajorPerformanceCaveat: true,
|
|
88
|
+
desynchronized: lowLatency,
|
|
74
89
|
};
|
|
75
90
|
const gl = canvas.getContext("webgl", glAttrs) || canvas.getContext("experimental-webgl", glAttrs);
|
|
76
91
|
this.gl = gl;
|
|
77
92
|
if (!gl) {
|
|
78
93
|
throw new Error("Unable to create a GL context");
|
|
79
94
|
}
|
|
95
|
+
reportDesynchronized(gl, lowLatency);
|
|
80
96
|
const checkedGl = webglDebug.makeDebugContext(gl, function (err, funcName) {
|
|
81
97
|
throw new Error("Problem creating GL context: " + webglDebug.glEnumToString(err) + " in " + funcName);
|
|
82
98
|
});
|
|
@@ -260,10 +276,10 @@ export function useBestFilter(canvas, filterClass) {
|
|
|
260
276
|
return fellBackBecause(canvas, reason);
|
|
261
277
|
}
|
|
262
278
|
|
|
263
|
-
export function bestCanvas(canvas, filterClass) {
|
|
279
|
+
export function bestCanvas(canvas, filterClass, lowLatency = true) {
|
|
264
280
|
let reason;
|
|
265
281
|
try {
|
|
266
|
-
return new GlCanvas(canvas, filterClass);
|
|
282
|
+
return new GlCanvas(canvas, filterClass, lowLatency);
|
|
267
283
|
} catch (e) {
|
|
268
284
|
// Either WebGL is unavailable or this particular filter declined it.
|
|
269
285
|
reason = e?.message ?? e;
|
|
@@ -275,11 +291,11 @@ export function bestCanvas(canvas, filterClass) {
|
|
|
275
291
|
// 2D fallback below would throw and take the emulator with it.
|
|
276
292
|
if (filterClass !== PassthroughFilter) {
|
|
277
293
|
try {
|
|
278
|
-
return fellBackBecause(new GlCanvas(canvas, PassthroughFilter), reason);
|
|
294
|
+
return fellBackBecause(new GlCanvas(canvas, PassthroughFilter, lowLatency), reason);
|
|
279
295
|
} catch (e) {
|
|
280
296
|
console.log("Unable to fall back to the passthrough filter: " + e);
|
|
281
297
|
}
|
|
282
298
|
}
|
|
283
299
|
|
|
284
|
-
return fellBackBecause(new Canvas(canvas), reason);
|
|
300
|
+
return fellBackBecause(new Canvas(canvas, lowLatency), reason);
|
|
285
301
|
}
|
package/src/main.js
CHANGED
|
@@ -374,6 +374,10 @@ let tryGl = true;
|
|
|
374
374
|
if (parsedQuery.glEnabled !== undefined) {
|
|
375
375
|
tryGl = parsedQuery.glEnabled === "true";
|
|
376
376
|
}
|
|
377
|
+
let lowLatency = true;
|
|
378
|
+
if (parsedQuery.lowLatency !== undefined) {
|
|
379
|
+
lowLatency = parsedQuery.lowLatency === "true";
|
|
380
|
+
}
|
|
377
381
|
const screenCanvas = document.getElementById("screen");
|
|
378
382
|
|
|
379
383
|
const errorDialog = document.getElementById("error-dialog");
|
|
@@ -516,7 +520,9 @@ function createCanvasForFilter(filterClass) {
|
|
|
516
520
|
// creating the context, which fixes its initial viewport.
|
|
517
521
|
sizeCanvasFor(filterClass);
|
|
518
522
|
|
|
519
|
-
const newCanvas = tryGl
|
|
523
|
+
const newCanvas = tryGl
|
|
524
|
+
? canvasLib.bestCanvas(screenCanvas, filterClass, lowLatency)
|
|
525
|
+
: new canvasLib.Canvas(screenCanvas, lowLatency);
|
|
520
526
|
reportAnyFallback(newCanvas, filterClass);
|
|
521
527
|
return newCanvas;
|
|
522
528
|
}
|
package/src/snapshot-helpers.js
CHANGED
package/src/teletext.js
CHANGED
|
@@ -12,7 +12,7 @@ export class Teletext {
|
|
|
12
12
|
this.dbl = this.oldDbl = this.secondHalfOfDouble = this.wasDbl = false;
|
|
13
13
|
this.gfx = false;
|
|
14
14
|
this.conceal = false;
|
|
15
|
-
this.flash =
|
|
15
|
+
this.flash = false;
|
|
16
16
|
this.flashTime = 0;
|
|
17
17
|
this.heldChar = 0;
|
|
18
18
|
this.holdChar = false;
|
|
@@ -175,7 +175,6 @@ export class Teletext {
|
|
|
175
175
|
gfx: this.gfx,
|
|
176
176
|
conceal: this.conceal,
|
|
177
177
|
flash: this.flash,
|
|
178
|
-
flashOn: this.flashOn,
|
|
179
178
|
flashTime: this.flashTime,
|
|
180
179
|
heldChar: this.heldChar,
|
|
181
180
|
holdChar: this.holdChar,
|
|
@@ -202,7 +201,6 @@ export class Teletext {
|
|
|
202
201
|
this.gfx = state.gfx;
|
|
203
202
|
this.conceal = state.conceal ?? false;
|
|
204
203
|
this.flash = state.flash;
|
|
205
|
-
this.flashOn = state.flashOn;
|
|
206
204
|
this.flashTime = state.flashTime;
|
|
207
205
|
this.heldChar = state.heldChar;
|
|
208
206
|
this.holdChar = state.holdChar;
|
|
@@ -300,7 +298,6 @@ export class Teletext {
|
|
|
300
298
|
}
|
|
301
299
|
if (wasGfx && (wasHoldChar || this.holdChar) && this.dbl === this.oldDbl) {
|
|
302
300
|
data = this.heldChar;
|
|
303
|
-
if (data >= 0x40 && data < 0x60) data = 0x20;
|
|
304
301
|
this.curGlyphs = this.heldGlyphs;
|
|
305
302
|
} else {
|
|
306
303
|
this.heldChar = 0x20;
|
|
@@ -333,13 +330,14 @@ export class Teletext {
|
|
|
333
330
|
|
|
334
331
|
// 3:1 flash ratio.
|
|
335
332
|
if (++this.flashTime === 64) this.flashTime = 0;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Flashing text starts off in sync with a slow cursor, extinguished together. Multiple MODE
|
|
336
|
+
// changes gradually desynchronise the frame counters.
|
|
337
|
+
// TODO: setDEW is reached a MOS-dependent number of times before Video.frameCount rises, so
|
|
338
|
+
// the initial sync here holds under MOS 1.20 only.
|
|
339
|
+
get hideFlashing() {
|
|
340
|
+
return this.flashTime < 16;
|
|
343
341
|
}
|
|
344
342
|
|
|
345
343
|
setDISPTMG(level) {
|
|
@@ -429,7 +427,7 @@ export class Teletext {
|
|
|
429
427
|
// Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
|
|
430
428
|
if (this.conceal) concealThisCell = true;
|
|
431
429
|
|
|
432
|
-
if (concealThisCell || (flashThisCell && this.
|
|
430
|
+
if (concealThisCell || (flashThisCell && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
|
|
433
431
|
const backgroundColour = this.colour[(this.bg & 7) << 5];
|
|
434
432
|
for (let i = 0; i < 16; ++i) {
|
|
435
433
|
buf[offset++] = backgroundColour;
|
package/src/url-params.js
CHANGED
|
@@ -41,18 +41,15 @@ export const ParamTypes = {
|
|
|
41
41
|
export function parseQueryString(queryString, paramTypes = {}) {
|
|
42
42
|
if (!queryString) return {};
|
|
43
43
|
|
|
44
|
-
// workaround for shonky python web server
|
|
45
|
-
const cleanQueryString = queryString.endsWith("/") ? queryString.substring(0, queryString.length - 1) : queryString;
|
|
46
|
-
|
|
47
44
|
const parsedQuery = {};
|
|
48
45
|
|
|
49
|
-
|
|
46
|
+
queryString.split("&").forEach(function (keyval) {
|
|
50
47
|
if (!keyval) return;
|
|
51
48
|
|
|
52
49
|
const keyAndVal = keyval.split("=");
|
|
53
50
|
const key = decodeURIComponent(keyAndVal[0]);
|
|
54
51
|
let val = null;
|
|
55
|
-
if (keyAndVal.length > 1) val = decodeURIComponent(keyAndVal
|
|
52
|
+
if (keyAndVal.length > 1) val = decodeURIComponent(keyAndVal.slice(1).join("="));
|
|
56
53
|
|
|
57
54
|
const paramType = paramTypes[key] || ParamTypes.STRING;
|
|
58
55
|
|
|
@@ -90,12 +87,26 @@ export function parseQueryString(queryString, paramTypes = {}) {
|
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* @param {Object.<string, ParamType>} [paramTypes={}] - Object mapping parameter names to their types
|
|
97
|
-
* @returns {string} The complete URL with query parameters
|
|
90
|
+
* Characters RFC 3986 allows literally in a query that `encodeURIComponent` escapes anyway. `&`,
|
|
91
|
+
* `=`, `+`, `#`, `%` and space are left escaped because they delimit something, and `?` because a
|
|
92
|
+
* URL with a second one in it reads as broken even though the grammar permits it.
|
|
98
93
|
*/
|
|
94
|
+
const QueryLiterals = "$,/:;@";
|
|
95
|
+
|
|
96
|
+
const EscapedQueryLiterals = new RegExp(
|
|
97
|
+
[...QueryLiterals].map((char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`).join("|"),
|
|
98
|
+
"g",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Percent-encode a key or value for a query string, escaping only what delimits something
|
|
103
|
+
* @param {string} component - The key or value to encode
|
|
104
|
+
* @returns {string} The encoded component
|
|
105
|
+
*/
|
|
106
|
+
function encodeQueryComponent(component) {
|
|
107
|
+
return encodeURIComponent(component).replace(EscapedQueryLiterals, decodeURIComponent);
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
/**
|
|
100
111
|
* Append a parameter to the URL
|
|
101
112
|
* @param {string} url - Current URL
|
|
@@ -105,13 +116,20 @@ export function parseQueryString(queryString, paramTypes = {}) {
|
|
|
105
116
|
* @returns {Object} Updated URL and separator
|
|
106
117
|
*/
|
|
107
118
|
function appendParam(url, sep, key, value = undefined) {
|
|
108
|
-
url += sep +
|
|
119
|
+
url += sep + encodeQueryComponent(key);
|
|
109
120
|
if (value !== undefined) {
|
|
110
|
-
url += "=" +
|
|
121
|
+
url += "=" + encodeQueryComponent(value);
|
|
111
122
|
}
|
|
112
123
|
return { url, sep: "&" };
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Build a URL string from base URL and query parameters
|
|
128
|
+
* @param {string} baseUrl - The base URL (without query string)
|
|
129
|
+
* @param {Object} parsedQuery - Object containing query parameters
|
|
130
|
+
* @param {Object.<string, ParamType>} [paramTypes={}] - Object mapping parameter names to their types
|
|
131
|
+
* @returns {string} The complete URL with query parameters
|
|
132
|
+
*/
|
|
115
133
|
export function buildUrlFromParams(baseUrl, parsedQuery, paramTypes = {}) {
|
|
116
134
|
let url = baseUrl;
|
|
117
135
|
let sep = "?";
|
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
const lowPassFilterFreq = sampleRate / 2;
|
|
4
4
|
const RC = 1 / (2 * Math.PI * lowPassFilterFreq);
|
|
5
5
|
|
|
6
|
+
const InputSampleRate = 4000000.0 / 8;
|
|
7
|
+
const MaxQueuedMs = 250;
|
|
8
|
+
|
|
9
|
+
const samplesFor = (ms) => (InputSampleRate * ms) / 1000;
|
|
10
|
+
|
|
6
11
|
class SoundChipProcessor extends AudioWorkletProcessor {
|
|
7
12
|
constructor(...args) {
|
|
8
13
|
super(...args);
|
|
9
14
|
|
|
10
|
-
this.inputSampleRate =
|
|
15
|
+
this.inputSampleRate = InputSampleRate;
|
|
11
16
|
this._lastSample = 0;
|
|
12
17
|
this._lastFilteredOutput = 0;
|
|
13
18
|
this.queue = [];
|
|
14
|
-
this.
|
|
19
|
+
this._queueSizeSamples = 0;
|
|
15
20
|
this.dropped = 0;
|
|
16
21
|
this.underruns = 0;
|
|
17
22
|
this.targetLatencyMs = 1000 * (1 / 50); // One frame
|
|
18
|
-
this.
|
|
23
|
+
this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
|
|
19
24
|
this.running = false;
|
|
20
|
-
this.
|
|
25
|
+
this.maxQueueSizeSamples = samplesFor(MaxQueuedMs);
|
|
21
26
|
this.port.onmessage = (event) => {
|
|
22
27
|
// TODO: even better than this, send over register settings/catch up and run the audio work _here_
|
|
23
28
|
this.onBuffer(event.data.time, event.data.buffer);
|
|
@@ -47,19 +52,19 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
47
52
|
|
|
48
53
|
onBuffer(time, buffer) {
|
|
49
54
|
this.queue.push({ offset: 0, time, buffer });
|
|
50
|
-
this.
|
|
55
|
+
this._queueSizeSamples += buffer.length;
|
|
51
56
|
this.cleanQueue();
|
|
52
|
-
if (!this.running && this.
|
|
57
|
+
if (!this.running && this._queueSizeSamples >= this.startQueueSizeSamples) this.running = true;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
_shift() {
|
|
56
61
|
const dropped = this.queue.shift();
|
|
57
|
-
this.
|
|
62
|
+
this._queueSizeSamples -= dropped.buffer.length;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
cleanQueue() {
|
|
61
66
|
const maxLatency = this.targetLatencyMs * 2;
|
|
62
|
-
while (this.
|
|
67
|
+
while (this._queueSizeSamples > this.maxQueueSizeSamples || this._queueAge() > maxLatency) {
|
|
63
68
|
this._shift();
|
|
64
69
|
this.dropped++;
|
|
65
70
|
}
|
package/tests/test-machine.js
CHANGED
|
@@ -138,17 +138,13 @@ export class TestMachine {
|
|
|
138
138
|
throw new Error(`Cursor did not reach state ${on} in time (cursorOnThisFrame=${video.cursorOnThisFrame})`);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
* Run until the teletext flash state reaches the desired phase.
|
|
143
|
-
* @param {boolean} on - true for flash-on (flashing cells blanked), false for flash-off
|
|
144
|
-
*/
|
|
145
|
-
async runToFlashState(on) {
|
|
141
|
+
async runUntilFlashHidden() {
|
|
146
142
|
const teletext = this.processor.video.teletext;
|
|
147
143
|
for (let i = 0; i < 100; i++) {
|
|
148
|
-
if (teletext.
|
|
144
|
+
if (teletext.hideFlashing) return;
|
|
149
145
|
await this.runFor(40000);
|
|
150
146
|
}
|
|
151
|
-
throw new Error(
|
|
147
|
+
throw new Error("Flashing text did not reach its hidden phase in time");
|
|
152
148
|
}
|
|
153
149
|
|
|
154
150
|
async runUntilVblank() {
|