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 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%3ASuperior%2FSpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY)
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%3ASuperior%2FFrogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY)
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%3ASuperior%2FHunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL)
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%3ASuperior%2FSpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY)
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.18.0",
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
- preserveDrawingBuffer: false,
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 ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
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
  }
@@ -139,7 +139,6 @@ export function buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, c
139
139
  wasDbl: false,
140
140
  gfx: false,
141
141
  flash: false,
142
- flashOn: false,
143
142
  flashTime: 0,
144
143
  heldChar: 0,
145
144
  holdChar: false,
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 = this.flashOn = false;
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
- // Flashing text starts off in sync with a slow cursor, extinguished
337
- // together. Multiple MODE changes gradually desynchronise the
338
- // frame counters.
339
- // TODO: this point is being reached a MOS-dependent number of times
340
- // before Video.frameCount rises. The next line achieves initial
341
- // sync under MOS 1.20 only.
342
- this.flashOn = this.flashTime < 16;
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.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
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
- cleanQueryString.split("&").forEach(function (keyval) {
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[1]);
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
- * Build a URL string from base URL and query parameters
94
- * @param {string} baseUrl - The base URL (without query string)
95
- * @param {Object} parsedQuery - Object containing query parameters
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 + encodeURIComponent(key);
119
+ url += sep + encodeQueryComponent(key);
109
120
  if (value !== undefined) {
110
- url += "=" + encodeURIComponent(value);
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 = 4000000.0 / 8;
15
+ this.inputSampleRate = InputSampleRate;
11
16
  this._lastSample = 0;
12
17
  this._lastFilteredOutput = 0;
13
18
  this.queue = [];
14
- this._queueSizeBytes = 0;
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.startQueueSizeBytes = this.inputSampleRate / this.targetLatencyMs / 2;
23
+ this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
19
24
  this.running = false;
20
- this.maxQueueSizeBytes = this.inputSampleRate * 0.25;
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._queueSizeBytes += buffer.length;
55
+ this._queueSizeSamples += buffer.length;
51
56
  this.cleanQueue();
52
- if (!this.running && this._queueSizeBytes >= this.startQueueSizeBytes) this.running = true;
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._queueSizeBytes -= dropped.buffer.length;
62
+ this._queueSizeSamples -= dropped.buffer.length;
58
63
  }
59
64
 
60
65
  cleanQueue() {
61
66
  const maxLatency = this.targetLatencyMs * 2;
62
- while (this._queueSizeBytes > this.maxQueueSizeBytes || this._queueAge() > maxLatency) {
67
+ while (this._queueSizeSamples > this.maxQueueSizeSamples || this._queueAge() > maxLatency) {
63
68
  this._shift();
64
69
  this.dropped++;
65
70
  }
@@ -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.flashOn === on) return;
144
+ if (teletext.hideFlashing) return;
149
145
  await this.runFor(40000);
150
146
  }
151
- throw new Error(`Flash did not reach state ${on} in time (flashOn=${teletext.flashOn})`);
147
+ throw new Error("Flashing text did not reach its hidden phase in time");
152
148
  }
153
149
 
154
150
  async runUntilVblank() {