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 +8 -2
- package/package.json +15 -6
- package/src/6502.js +14 -7
- package/src/6847.js +50 -8
- package/src/acia.js +2 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/canvas.js +85 -19
- package/src/disc-drive.js +54 -12
- package/src/disc-hfe.js +19 -3
- package/src/disc-surface.js +350 -0
- package/src/disc-visualiser.js +569 -0
- package/src/disc.js +226 -62
- package/src/econet.js +6 -2
- package/src/fdc.js +83 -11
- package/src/google-drive.js +4 -4
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +191 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +389 -152
- package/src/models.js +15 -3
- package/src/sth.js +25 -22
- package/src/tapes.js +10 -12
- package/src/teletext_adaptor.js +6 -4
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- package/src/url-params.js +28 -0
- package/src/utils.js +3 -0
- package/src/video-filters/pal-composite.js +14 -48
- package/src/video-filters/passthrough-filter.js +11 -39
- package/src/video-filters/pixel-grid.js +55 -0
- package/src/video-filters/shader-program.js +53 -0
- package/src/video-filters/shaders/xbr.frag.glsl +278 -0
- package/src/video-filters/shaders/xbr.vert.glsl +7 -0
- package/src/video-filters/xbr-filter.js +107 -0
- package/src/video.js +56 -16
- package/src/wd-fdc.js +25 -8
- package/src/web/audio-handler.js +4 -2
- package/src/web/toast.js +79 -0
- package/tests/test-machine.js +8 -6
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
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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(
|
|
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"),
|
package/src/bbcdiscs.js
ADDED
|
@@ -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
|
-
|
|
17
|
-
|
|
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.
|
|
31
|
-
this.canvasHeight = canvas.height;
|
|
33
|
+
this.canvas = canvas;
|
|
32
34
|
|
|
33
35
|
this.fb32 = new Uint32Array(this.imageData.data.buffer);
|
|
34
36
|
}
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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,
|
|
85
|
-
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER,
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
241
|
+
|
|
242
|
+
return fellBackBecause(new Canvas(canvas), reason);
|
|
177
243
|
}
|