jsbeeb 1.15.0 → 1.17.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 CHANGED
@@ -240,10 +240,16 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
240
240
  ## URL Parameters
241
241
 
242
242
  - `autoboot` - fakes a shift break
243
- - `disc1=XXX` - loads disc XXX (from the `discs/` directory) into drive 1
244
- - `disc2=XXX` - as above
243
+ - `disc1=XXX` - loads disc XXX (from the `discs/` directory) into drive 0
244
+ - `disc2=XXX` - as above, into drive 1
245
245
  - `disc1=local:YYY` - creates a local disk YYY which will be kept in browser local storage
246
246
  - `disc1=sth:ZZZ` - loads disc ZZZ from the Stairway to Hell archive
247
+ - `drive0Tracks=40` / `drive0Tracks=80` - fixes drive 0's 40/80 track switch, as the switch on the back of a real
248
+ drive did. `drive1Tracks` does the same for drive 1. Left alone, each drive follows whatever disc is loaded into it:
249
+ a 40 track image is laid out the way a 40 track drive wrote it, on every other track of the surface, and the drive
250
+ double steps to read it. `40` reads an 80 track disc through a double stepping head, which is as much of a mess as it
251
+ was in 1985. `80` turns all of this off for that drive, loading every image the way jsbeeb did before it could tell
252
+ them apart. [docs/disc-track-layouts.md](docs/disc-track-layouts.md) explains how an image's layout is worked out.
247
253
  - `tape=XXX` - loads tape XXX (from the `tapes/` directory)
248
254
  - `tape=sth:ZZZ` - loads tape ZZZ from the Stairway to Hell archive
249
255
  - `KEY.X=Y` - makes host key `X` press BBC key `Y`, e.g. `KEY.ENTER=COPY`. See
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.15.0",
10
+ "version": "1.17.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"
@@ -44,6 +44,7 @@
44
44
  "jsdom": "^30.0.1",
45
45
  "lint-staged": "^17.1.1",
46
46
  "npm-run-all2": "^9.0.2",
47
+ "papaparse": "^5.5.4",
47
48
  "pixelmatch": "^7.2.0",
48
49
  "prettier": "^3.9.6",
49
50
  "vite": "^8.1.5",
@@ -54,10 +55,6 @@
54
55
  "electron-builder": "^26.15.3",
55
56
  "electron-store": "^11.0.2"
56
57
  },
57
- "//overrides": "brace-expansion forced to the patched version for GHSA-mh99-v99m-4gvg; the electron-builder dependency chain still pins vulnerable versions. Remove once `npm audit` is clean without it.",
58
- "overrides": {
59
- "brace-expansion": "^5.0.8"
60
- },
61
58
  "license": "GPL-3.0-or-later",
62
59
  "build": {
63
60
  "appId": "org.godbolt.bbc",
@@ -99,15 +96,27 @@
99
96
  "test:unit": "vitest run --silent=true tests/unit",
100
97
  "test:integration": "vitest run --silent=true tests/integration",
101
98
  "test:cpu": "node tests/test-suite.js",
99
+ "test:shader": "vitest run --silent=true tests/shader",
102
100
  "coverage:unit": "vitest run tests/unit --coverage",
103
101
  "coverage:all-tests": "vitest run --coverage",
104
102
  "benchmark": "node src/app-bench.js",
103
+ "mirror-sth": "node tools/mirror-sth.js --out .sth-mirror",
104
+ "mirror-sth:check": "node tools/mirror-sth.js --index-only",
105
+ "mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
106
+ "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
+ "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:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
110
+ "mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
111
+ "mirror-bbcdiscs:upload": "npm-run-all mirror-bbcdiscs:upload:*",
112
+ "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
+ "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'",
105
114
  "electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
106
115
  "electron:build": "electron-builder"
107
116
  },
108
117
  "lint-staged": {
109
118
  "*.js": [
110
- "vitest related --run --exclude tests/integration --no-file-parallelism",
119
+ "vitest related --run --exclude tests/integration --exclude tests/shader --no-file-parallelism",
111
120
  "eslint --cache --fix",
112
121
  "prettier --write"
113
122
  ],
package/src/6502.js CHANGED
@@ -213,6 +213,9 @@ class Base6502 {
213
213
  if (this._nmiLevel && !prevLevel) this._nmiEdge = true;
214
214
  }
215
215
 
216
+ /** Called as the NMI vector is taken, for devices that withdraw their request at that point. */
217
+ nmiAcknowledged() {}
218
+
216
219
  polltime() {
217
220
  throw new Error("Must be overridden");
218
221
  }
@@ -239,6 +242,7 @@ class Base6502 {
239
242
  if ((this.model.nmos || isIrq) && this._nmiEdge) {
240
243
  vector = 0xfffa;
241
244
  this._nmiEdge = false;
245
+ this.nmiAcknowledged();
242
246
  }
243
247
  this.takeInt = false;
244
248
  this.pc = this.readmem(vector) | (this.readmem(vector + 1) << 8);
@@ -451,6 +455,10 @@ class Tube6502 extends Base6502 {
451
455
  return this.memory[offset & 0xffff];
452
456
  }
453
457
 
458
+ nmiAcknowledged() {
459
+ this.tube.acknowledgeNmi();
460
+ }
461
+
454
462
  writemem(addr, b) {
455
463
  if ((addr & 0xfff8) === 0xfef8) {
456
464
  return this.tube.parasiteWrite(addr, b);
@@ -666,7 +674,7 @@ export class Cpu6502 extends Base6502 {
666
674
  this.resetLine = true;
667
675
  this.cpuMultiplier = this.config.cpuMultiplier;
668
676
  this.videoCyclesBatch = this.config.videoCyclesBatch | 0;
669
- this.peripheralCyclesPerSecond = 2 * 1000 * 1000;
677
+ this.peripheralCyclesPerSecond = model.cyclesPerSecond;
670
678
  this.hasTube = !!this.config.tube;
671
679
  this.hasMusic5000 = !!this.config.hasMusic5000;
672
680
  this.hasTeletextAdaptor = !!this.config.hasTeletextAdaptor;
@@ -1361,7 +1369,7 @@ export class Cpu6502 extends Base6502 {
1361
1369
  this.fdc.powerOnReset();
1362
1370
  this.adconverter.reset();
1363
1371
 
1364
- this.touchScreen = new TouchScreen(this.scheduler);
1372
+ this.touchScreen = new TouchScreen(this.scheduler, this.model.cyclesPerSecond);
1365
1373
  if (this.econet) this.filestore = new Filestore(this, this.econet);
1366
1374
  }
1367
1375
 
@@ -1478,9 +1486,10 @@ export class Cpu6502 extends Base6502 {
1478
1486
  // that from both, to keep the domain low (while accumulating seconds). Take care to preserve the bottom
1479
1487
  // bit though; as that encodes whether we're on an even or odd bus cycle.
1480
1488
  const smaller = Math.min(this.targetCycles, this.currentCycles) & 0xfffffffe;
1481
- if (smaller >= 2 * 1000 * 1000) {
1482
- this.targetCycles -= 2 * 1000 * 1000;
1483
- this.currentCycles -= 2 * 1000 * 1000;
1489
+ const cyclesPerSecond = this.model.cyclesPerSecond;
1490
+ if (smaller >= cyclesPerSecond) {
1491
+ this.targetCycles -= cyclesPerSecond;
1492
+ this.currentCycles -= cyclesPerSecond;
1484
1493
  this.cycleSeconds++;
1485
1494
  }
1486
1495
  // Any tracing or debugging means we need to run the potentially slower version: the debug read or
@@ -1614,8 +1623,6 @@ export class AtomCpu6502 extends Cpu6502 {
1614
1623
  this.ramRomOs = expanded;
1615
1624
  }
1616
1625
 
1617
- // Atom runs at 1 MHz
1618
- this.peripheralCyclesPerSecond = 1 * 1000 * 1000;
1619
1626
  // reset() and debugger.setCpu() are called by initialise() after loadOs().
1620
1627
  }
1621
1628
 
package/src/6847.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  import * as utils from "./utils.js";
3
3
  import * as fontData from "./6847_fontdata.js";
4
+ import { encodeLineGrid } from "./video-filters/pixel-grid.js";
4
5
 
5
6
  const VDISPENABLE = 1 << 0,
6
7
  HDISPENABLE = 1 << 1,
@@ -111,6 +112,11 @@ only 1 bit is used of SG6 - to get yellow/red, cyan/orange
111
112
  // constant for the graphics mode
112
113
  const MODE_AG = 0x10; // graphics mode
113
114
 
115
+ // The MC6847 datasheet specifies 3.579545 MHz, but 3.638004 is used here as an
116
+ // empirical correction to align VSync/interrupt timing with the CPU clock.
117
+ // TODO: revisit once full integration is testable.
118
+ const VdgClockMhz = 3.638004;
119
+
114
120
  export class Video6847 {
115
121
  constructor(video) {
116
122
  this.video = video;
@@ -175,6 +181,7 @@ export class Video6847 {
175
181
  this.bitmapPxPerPixel = 2; // each pixel is 2 bitmap pixels wide and high
176
182
  this.pixelsPerBit = this.bitmapPxPerPixel;
177
183
  this.bpp = 1;
184
+ this.updateLineGrid();
178
185
 
179
186
  this.cpuAddr = 0;
180
187
  this.dispEnabled = 0;
@@ -225,6 +232,9 @@ export class Video6847 {
225
232
  reset(cpu, ppia) {
226
233
  this.cpu = cpu;
227
234
  this.ppia = ppia;
235
+ // polltime() is handed CPU cycles, so how far the VDG advances per
236
+ // cycle depends on how fast the CPU it's attached to runs.
237
+ this.vdgCyclesPerCpuCycle = cpu ? VdgClockMhz / cpu.model.clockMhz : 0;
228
238
  }
229
239
 
230
240
  // USE PAINT from VIDEO
@@ -315,6 +325,7 @@ export class Video6847 {
315
325
  this.pixelsPerBit = this.bitmapPxPerPixel * this.modes[mode][1];
316
326
  let linesPerRow = this.modes[mode][2]; // move to reg9
317
327
  this.bpp = this.modes[mode][3];
328
+ this.updateLineGrid();
318
329
 
319
330
  this.charLinesreg9 = linesPerRow - 1; //2 - scanlines per char
320
331
 
@@ -344,11 +355,7 @@ export class Video6847 {
344
355
  // it doesn't draw the whole frame. It regularly gives control back to the CPU.
345
356
 
346
357
  const vdgcharclock = this.pixelsPerChar / 2; // 4 or 8
347
- // The MC6847 datasheet specifies 3.579545 MHz, but 3.638004 is used here
348
- // as an empirical correction to align VSync/interrupt timing with the Atom's
349
- // 1 MHz CPU clock. TODO: revisit once full integration is testable.
350
- const vdgclock = 3.638004;
351
- this.vdg_cycles += clocks * vdgclock;
358
+ this.vdg_cycles += clocks * this.vdgCyclesPerCpuCycle;
352
359
 
353
360
  const vdgframelines = 262; // 312 PAL (but no pal on standard atom) 262; // NTSC
354
361
  const vdglinetime = 228; // vdg cycles to do a line; not 227.5
@@ -491,9 +498,9 @@ export class Video6847 {
491
498
  {
492
499
  // TODO: Add in the INTEXT modifiers to mode (if necessary)
493
500
  // blit into the fb32 buffer which is painted by VIDEO
494
- if ((mode & MODE_AG) === 0)
495
- // MODE_AG - bit 4; 0x10 is the AG bit
496
- this.blitChar(this.video.fb32, dat, offset, this.pixelsPerChar, css);
501
+ const textMode = (mode & MODE_AG) === 0; // 0x10 is the AG bit
502
+ this.recordLineGrid(textMode);
503
+ if (textMode) this.blitChar(this.video.fb32, dat, offset, this.pixelsPerChar, css);
497
504
  else this.blitPixels(this.video.fb32, dat, offset, css);
498
505
  }
499
506
  }
@@ -619,6 +626,41 @@ export class Video6847 {
619
626
  }
620
627
  }
621
628
 
629
+ /**
630
+ * Precompute this mode's grid descriptors, one per blitter.
631
+ *
632
+ * The two blitters derive their widths differently and cannot be unified:
633
+ * `blitChar` splits the character's texels across eight glyph bits, while
634
+ * `blitPixels` takes the bit width from the mode table — which for text
635
+ * mode holds -1, since it does not blit pixels at all.
636
+ *
637
+ * `pixelsPerBit` is right for graphics at 2bpp as well as 1bpp: the blitter
638
+ * steps its bit groups by `pixelsPerBit / bpp`, but reads the colour with
639
+ * `j & 0xe`, so pairs of groups share one and each pixel is twice as wide.
640
+ */
641
+ updateLineGrid() {
642
+ // Every 6847 blitter writes each pixel row into two framebuffer lines.
643
+ // Text mode's entry is only reachable through blitChar, which is just
644
+ // as well: the mode table holds -1 for it and would not encode.
645
+ this.lineGridText = encodeLineGrid((this.pixelsPerChar * this.bitmapPxPerPixel) / 8, true);
646
+ this.lineGridGraphics = this.pixelsPerBit > 0 ? encodeLineGrid(this.pixelsPerBit, true) : this.lineGridText;
647
+ }
648
+
649
+ /**
650
+ * Note the logical pixel size of the two rows about to be written, so
651
+ * display filters can see the picture as pixels rather than as raster
652
+ * samples (see video-filters/pixel-grid.js). Only the picture records a
653
+ * grid: the border is a solid colour, and letting it write here would
654
+ * leave the row describing the border rather than the picture on it.
655
+ *
656
+ * @param {boolean} textMode whether the character blitter is about to run
657
+ */
658
+ recordLineGrid(textMode) {
659
+ const grid = textMode ? this.lineGridText : this.lineGridGraphics;
660
+ this.video.lineGrid[this.bitmapY] = grid;
661
+ this.video.lineGrid[this.bitmapY + 1] = grid;
662
+ }
663
+
622
664
  blitChar(buf, data, destOffset, numPixels, css) {
623
665
  const scanline = this.scanlineCounter;
624
666
  const chr = data & 0x7f;
package/src/acia.js CHANGED
@@ -265,8 +265,9 @@ export class Acia {
265
265
  }
266
266
  }
267
267
 
268
+ // Byte times are held in CPU cycles because that's what the scheduler counts.
268
269
  secondsToCycles(sec) {
269
- return Math.floor(2 * 1000 * 1000 * sec) | 0;
270
+ return Math.floor(this.cpu.model.cyclesPerSecond * sec) | 0;
270
271
  }
271
272
 
272
273
  numBitsPerByte() {
package/src/app/app.js CHANGED
@@ -172,6 +172,10 @@ const template = [
172
172
  label: "Browse STH Disc Archive...",
173
173
  click: showModal("sth", { sthType: "discs" }),
174
174
  },
175
+ {
176
+ label: "Browse HFE Disc Archive...",
177
+ click: showModal("hfe"),
178
+ },
175
179
  {
176
180
  label: "Browse Example Discs...",
177
181
  click: showModal("discs"),
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+
3
+ const mirrorBase = "https://bbc.xania.org/archive/bbcdiscs";
4
+
5
+ // Numeric so a "Disc 2" would sort before a "Disc 10" rather than after it,
6
+ // and case-insensitive so a lower-cased title stays with its neighbours.
7
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
8
+
9
+ /**
10
+ * Order the picker by what someone is looking for, which is the disc's name.
11
+ * The catalogue arrives grouped by publisher, so it has to be sorted here; the
12
+ * remaining keys only settle ties, keeping a title's variants together and in
13
+ * a stable order rather than the one the catalogue happens to list them in.
14
+ */
15
+ export const byTitle = (a, b) =>
16
+ collator.compare(a.title || a.path, b.title || b.path) ||
17
+ collator.compare(a.publisher || "", b.publisher || "") ||
18
+ collator.compare(a.disc || "", b.disc || "") ||
19
+ collator.compare(a.variant || "", b.variant || "");
20
+
21
+ /**
22
+ * How a disc reads in the picker. Several fingerprinted variants of one title
23
+ * sit next to each other, so the title alone doesn't identify a disc.
24
+ *
25
+ * @param {object} file manifest entry
26
+ * @returns {{title: string, publisher: string, detail: string}}
27
+ */
28
+ export function describe(file) {
29
+ const detail = [file.disc, file.tracks?.join(", "), file.variant && `v${file.variant}`].filter(Boolean);
30
+ return {
31
+ title: file.title || file.path,
32
+ publisher: file.publisher ?? "",
33
+ detail: detail.join(" · "),
34
+ };
35
+ }
36
+
37
+ export class BbcDiscArchive {
38
+ /** @param {string} [baseUrl] where the mirror lives, to point at a test prefix */
39
+ constructor(onStart, onCat, onError, baseUrl = mirrorBase) {
40
+ this._baseUrl = `${baseUrl}/hfe/`;
41
+ this._catalogue = [];
42
+ this._loaded = false;
43
+ this._onStart = onStart;
44
+ this._onCat = onCat;
45
+ this._onError = onError;
46
+ }
47
+
48
+ async populate() {
49
+ this._onStart();
50
+ // Tracked separately from the catalogue: an archive can legitimately be
51
+ // empty, and an empty array would mean "fetch it again" every time.
52
+ if (!this._loaded) {
53
+ try {
54
+ const response = await fetch(`${this._baseUrl}manifest.json`);
55
+ if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
56
+ const data = await response.json();
57
+ if (!Array.isArray(data?.files)) throw new Error("Invalid manifest: missing files array");
58
+ this._catalogue = [...data.files].sort(byTitle);
59
+ this._loaded = true;
60
+ } catch (error) {
61
+ console.error("Failed to fetch HFE archive catalogue:", error);
62
+ if (this._onError) this._onError();
63
+ return;
64
+ }
65
+ }
66
+ if (this._onCat) this._onCat(this._catalogue);
67
+ }
68
+
69
+ /**
70
+ * Nothing to unzip, unlike sth.js: blobs are stored compressed and served
71
+ * with `Content-Encoding: br`, which the browser has undone by the time
72
+ * this resolves.
73
+ *
74
+ * @param {string} path a manifest entry's `path`
75
+ * @returns {Promise<Uint8Array>} the HFE image
76
+ */
77
+ async fetch(path) {
78
+ const url = this._baseUrl + encodeURIComponent(path);
79
+ console.log("Loading HFE from " + url);
80
+ const response = await fetch(url);
81
+ if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
82
+ return new Uint8Array(await response.arrayBuffer());
83
+ }
84
+ }
package/src/canvas.js CHANGED
@@ -2,10 +2,12 @@
2
2
  import webglDebug from "./lib/webgl-debug.js";
3
3
  import { PALCompositeFilter } from "./video-filters/pal-composite.js";
4
4
  import { PassthroughFilter } from "./video-filters/passthrough-filter.js";
5
+ import { XbrFilter } from "./video-filters/xbr-filter.js";
5
6
 
6
7
  const DISPLAY_MODE_FILTERS = {
7
8
  pal: PALCompositeFilter,
8
9
  rgb: PassthroughFilter,
10
+ xbr: XbrFilter,
9
11
  };
10
12
 
11
13
  export function getFilterForMode(mode) {
@@ -13,8 +15,9 @@ export function getFilterForMode(mode) {
13
15
  }
14
16
 
15
17
  export class Canvas {
16
- isWebGl() {
17
- return false;
18
+ /** The 2D canvas draws the framebuffer as-is, which is what this filter is. */
19
+ get filterClass() {
20
+ return PassthroughFilter;
18
21
  }
19
22
 
20
23
  constructor(canvas) {
@@ -27,24 +30,29 @@ export class Canvas {
27
30
  this.backBuffer.height = 625;
28
31
  this.backCtx = this.backBuffer.getContext("2d", { alpha: false });
29
32
  this.imageData = this.backCtx.createImageData(this.backBuffer.width, this.backBuffer.height);
30
- this.canvasWidth = canvas.width;
31
- this.canvasHeight = canvas.height;
33
+ this.canvas = canvas;
32
34
 
33
35
  this.fb32 = new Uint32Array(this.imageData.data.buffer);
34
36
  }
35
- paint(minx, miny, maxx, maxy, _frameCount) {
37
+
38
+ /** Nothing to release: the 2D context owns no objects of ours. */
39
+ dispose() {}
40
+
41
+ paint(minx, miny, maxx, maxy, _frame) {
36
42
  const width = maxx - minx;
37
43
  const height = maxy - miny;
38
44
  this.backCtx.putImageData(this.imageData, 0, 0, minx, miny, width, height);
39
- this.ctx.drawImage(this.backBuffer, minx, miny, width, height, 0, 0, this.canvasWidth, this.canvasHeight);
45
+ // Read the size each time: it can change when the window is resized.
46
+ this.ctx.drawImage(this.backBuffer, minx, miny, width, height, 0, 0, this.canvas.width, this.canvas.height);
40
47
  }
41
48
  }
42
49
 
43
50
  const width = 1024;
44
51
  const height = 1024;
45
52
  export class GlCanvas {
46
- isWebGl() {
47
- return true;
53
+ /** The filter actually built, which may not be the one that was asked for. */
54
+ get filterClass() {
55
+ return this.filter.constructor;
48
56
  }
49
57
 
50
58
  constructor(canvas, filterClass) {
@@ -74,6 +82,10 @@ export class GlCanvas {
74
82
  const program = this.filter.program;
75
83
  checkedGl.useProgram(program);
76
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
+
77
89
  this.fb8 = new Uint8Array(width * height * 4);
78
90
  this.fb32 = new Uint32Array(this.fb8.buffer);
79
91
  this.texture = checkedGl.createTexture();
@@ -81,8 +93,8 @@ export class GlCanvas {
81
93
  checkedGl.pixelStorei(checkedGl.UNPACK_ALIGNMENT, 4);
82
94
  checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_S, checkedGl.CLAMP_TO_EDGE);
83
95
  checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_T, checkedGl.CLAMP_TO_EDGE);
84
- checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER, checkedGl.LINEAR);
85
- checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER, checkedGl.LINEAR);
96
+ checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER, sampling);
97
+ checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER, sampling);
86
98
  checkedGl.texImage2D(
87
99
  checkedGl.TEXTURE_2D,
88
100
  0,
@@ -98,8 +110,8 @@ export class GlCanvas {
98
110
 
99
111
  const vertexPositionAttrLoc = checkedGl.getAttribLocation(program, "pos");
100
112
  checkedGl.enableVertexAttribArray(vertexPositionAttrLoc);
101
- const vertexPositionBuffer = checkedGl.createBuffer();
102
- checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, vertexPositionBuffer);
113
+ this.vertexPositionBuffer = checkedGl.createBuffer();
114
+ checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.vertexPositionBuffer);
103
115
  checkedGl.bufferData(checkedGl.ARRAY_BUFFER, new Float32Array([0, 0, 0, 1, 1, 0, 1, 1]), checkedGl.STATIC_DRAW);
104
116
  checkedGl.vertexAttribPointer(vertexPositionAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
105
117
 
@@ -112,14 +124,42 @@ export class GlCanvas {
112
124
  checkedGl.activeTexture(gl.TEXTURE0);
113
125
  checkedGl.bindTexture(gl.TEXTURE_2D, this.texture);
114
126
 
127
+ this.checkedGl = checkedGl;
128
+ this.viewportWidth = this.viewportHeight = 0;
115
129
  this.uvFloatArray = new Float32Array(8);
116
130
  this.lastExtent = {};
117
131
 
118
132
  console.log("GL Canvas set up");
119
133
  }
120
134
 
121
- paint(minx, miny, maxx, maxy, frameCount) {
135
+ /**
136
+ * Release the GL objects this canvas owns.
137
+ *
138
+ * Switching display mode builds a new canvas over the same element, and a
139
+ * canvas only ever hands out one WebGL context — so the new one inherits
140
+ * the old one's context and the old one's objects stay resident unless
141
+ * they are deleted here. That is a megabytes-per-switch leak: the
142
+ * framebuffer texture alone is 1024x1024 RGBA.
143
+ */
144
+ dispose() {
145
+ const gl = this.checkedGl;
146
+ this.filter.dispose();
147
+ gl.deleteTexture(this.texture);
148
+ gl.deleteBuffer(this.vertexPositionBuffer);
149
+ gl.deleteBuffer(this.uvBuffer);
150
+ this.texture = this.vertexPositionBuffer = this.uvBuffer = null;
151
+ }
152
+
153
+ paint(minx, miny, maxx, maxy, frame) {
122
154
  const gl = this.gl;
155
+ // The drawing buffer can be resized under us — modes that scale to the
156
+ // display do it on every window resize — and the viewport does not
157
+ // follow it.
158
+ if (gl.drawingBufferWidth !== this.viewportWidth || gl.drawingBufferHeight !== this.viewportHeight) {
159
+ this.viewportWidth = gl.drawingBufferWidth;
160
+ this.viewportHeight = gl.drawingBufferHeight;
161
+ gl.viewport(0, 0, this.viewportWidth, this.viewportHeight);
162
+ }
123
163
  // We can't specify a stride for the source, so have to use the full width.
124
164
  gl.texSubImage2D(
125
165
  gl.TEXTURE_2D,
@@ -157,21 +197,47 @@ export class GlCanvas {
157
197
  gl.bufferData(gl.ARRAY_BUFFER, this.uvFloatArray, gl.DYNAMIC_DRAW);
158
198
  }
159
199
 
160
- this.filter.setUniforms({ width, height, frameCount });
200
+ this.filter.setUniforms({
201
+ width,
202
+ height,
203
+ frameCount: frame.frameCount,
204
+ lineGrid: frame.lineGrid,
205
+ // How much of the framebuffer each output pixel covers, which sets
206
+ // how wide an edge-smoothing ramp should be. `extent` holds texel
207
+ // counts; the scaling into texture coordinates above applies only
208
+ // to the local copies that go into the UV buffer.
209
+ texelsPerOutputPixel: (extent.maxx - extent.minx) / gl.drawingBufferWidth,
210
+ });
161
211
 
162
212
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
163
213
  }
164
214
  }
165
215
 
216
+ function fellBackBecause(canvas, reason) {
217
+ canvas.fallbackReason = reason;
218
+ return canvas;
219
+ }
220
+
166
221
  export function bestCanvas(canvas, filterClass) {
222
+ let reason;
167
223
  try {
168
224
  return new GlCanvas(canvas, filterClass);
169
225
  } catch (e) {
170
- console.log("Unable to use OpenGL: " + e);
171
- if (filterClass.requiresGl()) {
172
- const config = filterClass.getDisplayConfig();
173
- console.warn(`${config.name} requires WebGL. Falling back to standard 2D canvas.`);
226
+ // Either WebGL is unavailable or this particular filter declined it.
227
+ reason = e?.message ?? e;
228
+ console.log(`Unable to use ${filterClass.getDisplayConfig().name} with WebGL: ${e}`);
229
+ }
230
+
231
+ // A canvas that has handed out a WebGL context can never hand out a 2D one,
232
+ // so if the failure came from the filter rather than from WebGL itself, the
233
+ // 2D fallback below would throw and take the emulator with it.
234
+ if (filterClass !== PassthroughFilter) {
235
+ try {
236
+ return fellBackBecause(new GlCanvas(canvas, PassthroughFilter), reason);
237
+ } catch (e) {
238
+ console.log("Unable to fall back to the passthrough filter: " + e);
174
239
  }
175
240
  }
176
- return new Canvas(canvas);
241
+
242
+ return fellBackBecause(new Canvas(canvas), reason);
177
243
  }