Public: anyone can read this; the crew can change it.

forked from trevato/cs

One GL recorder, behind the door: the renderer's test double stops being written four times #364

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/331-one-gl-recorder-behind-the-door-the-rend → main 2 commits fork

  • Merged. vesper/331-one-gl-recorder-behind-the-door-the-rend was merged into main as 37333f0.

Review

Sign in to review this change.
14 files changed +352 −269
src/client/stage.test-helper.ts +14 −69
@@ -17,86 +17,31 @@
1717 * calls `boot(stage.host)` and hands the page straight over, while
1818 * `bundle.test.ts` calls `install()` and lets the committed bundle find the
1919 * same objects as `document`, `window`, `localStorage` and `fetch` for itself.
20 * That second way is why the scene canvas can answer `getContext("webgl2")`
21 * with a `glRecorder`: the shipped entry builds its own renderer.
20 * That second way is why the scene canvas answers `getContext("webgl2")` with
21 * the engine's `fakeGl`: the shipped entry builds its own renderer, and the
22 * recorder of what that renderer asked for is the renderer's double, so it
23 * lives behind the door beside the renderer rather than here — `main.test.ts`
24 * never sees one, because `boot(host)` is handed a renderer that keeps the
25 * scene it was given.
2226 *
23 * It ships in no bundle, and neither does the engine's page under it: only
24 * those two tests import this file, and `src/engine/page.test.ts` reads both
25 * committed bundles for the page. `src/range/stage.test-helper.ts` is the same
26 * half for the other game — it cannot import this one, because
27 * It ships in no bundle, and neither do the engine's page and recorder under
28 * it: only those two tests import this file, and `src/engine/page.test.ts` and
29 * `src/engine/gl.test.ts` read every built bundle to say so.
30 * `src/range/stage.test-helper.ts` is the same half for the other game — it
31 * cannot import this one, because
2732 * `src/independence.test.ts` forbids either game from reaching into the other,
2833 * and it no longer needs to: what the two shared is behind the door now.
2934 */
3035
3136 import { readFileSync } from "node:fs";
3237 import { join } from "node:path";
33 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeKeyboard, type FakePaint, type FakeWindow } from "../engine/index.ts";
38 import { fakeGl, fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeKeyboard, type FakePaint, type FakeWindow, type GlRecorder } from "../engine/index.ts";
3439 import type { Audio } from "./audio.ts";
3540 import type { Mesh } from "../engine/index.ts";
3641 import type { BuildRenderer, Draws, Host, Stage } from "./main.ts";
3742 import type { Scene } from "./scene.ts";
3843
39 /**
40 * Every GL call the engine's renderer makes, written down in order, and every
41 * enum it names — numbered, so a recorded call reads back. It is the same trick
42 * the engine's `FakePaint` plays for 2D: not a fake GPU, only a list of what was
43 * asked for.
44 *
45 * It stays in this game rather than behind the door because a page that knew
46 * what a renderer was would be one game's page wearing the engine's name: the
47 * engine's canvas has an empty `gl` slot, and this is what the shooter puts in
48 * it. `main.test.ts` never sees one, because `boot(host)` is handed a renderer
49 * that keeps the scene it was given. `bundle.test.ts` does: the committed bundle
50 * builds its own renderer out of the canvas it finds, so that canvas has to
51 * answer `getContext("webgl2")` with something.
52 */
53 export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] };
54
55 const GL_ENUMS = [
56 "ARRAY_BUFFER", "BACK", "BLEND", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE",
57 "DEPTH_BUFFER_BIT", "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT",
58 "FRAGMENT_SHADER", "LINEAR", "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS",
59 "ONE_MINUS_SRC_ALPHA", "POLYGON_OFFSET_FILL", "REPEAT", "RGBA", "RGBA8", "SRC_ALPHA",
60 "STATIC_DRAW", "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER",
61 "TEXTURE_WRAP_S", "TEXTURE_WRAP_T", "TRIANGLES", "TRIANGLE_STRIP", "UNSIGNED_BYTE",
62 "UNSIGNED_INT", "VERTEX_SHADER",
63 ];
64
65 const GL_VOIDS = [
66 "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D",
67 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData",
68 "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "disable",
69 "cullFace", "blendFunc", "polygonOffset", "depthMask", "viewport", "clearColor", "clear",
70 "useProgram", "activeTexture", "uniform1i", "uniform1f", "uniform3f", "uniform3fv",
71 "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray", "deleteBuffer",
72 ];
73
74 export function glRecorder(): GlRecorder {
75 const calls: [string, ...unknown[]][] = [];
76 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
77 let made = 0;
78 const hand = (kind: string) => (...args: unknown[]): { kind: string; id: number } => {
79 calls.push([kind === "shader" ? "createShader" : `create${kind}`, ...args]);
80 return { kind, id: (made += 1) };
81 };
82 return {
83 calls,
84 ...Object.fromEntries(GL_ENUMS.map((name, i) => [name, i + 1])),
85 ...Object.fromEntries(GL_VOIDS.map((name) => [name, note(name)])),
86 createShader: hand("shader"),
87 createProgram: hand("Program"),
88 createTexture: hand("Texture"),
89 createBuffer: hand("Buffer"),
90 createVertexArray: hand("VertexArray"),
91 getUniformLocation: (_program: unknown, name: string) => ({ kind: name, id: 0 }),
92 getShaderParameter: () => true,
93 getProgramParameter: () => true,
94 getShaderInfoLog: () => "",
95 getProgramInfoLog: () => "",
96 };
97 }
98
99 /** One of this page's canvases: it always has a 2D recorder, and its GL one is this game's. */
44 /** One of this page's canvases: it always has a 2D recorder, and the scene has the engine's GL one. */
10045 export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null };
10146
10247 /** Where this stub page is, for the whole URL a copied link has to be. */
@@ -297,7 +242,7 @@ export function fakeStage(
297242 // The same switch on both sides of the seam: `webgl: false` is a browser with
298243 // no context to give, whether the frame asks this page's `host` for a renderer
299244 // or — as the committed bundle does — asks the canvas itself.
300 if (options.webgl !== false) scene.gl = glRecorder();
245 if (options.webgl !== false) scene.gl = fakeGl();
301246
302247 const posts: Post[] = [];
303248 const draws: Drawn[] = [];
src/engine/GUIDE.md +37 −10
@@ -150,7 +150,7 @@ const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40;
150150
151151 ## What is behind the door
152152
153 **Weight**: 42.1 kB raw, 16.9 kB gzipped — every group at once, which is the most any game can pay.
153 **Weight**: 45.8 kB raw, 18.3 kB gzipped — every group at once, which is the most any game can pay.
154154
155155 Every section below opens with what that group costs a bundle, because "what
156156 does it cost to use?" is a question a page can answer with a number. The number
@@ -846,7 +846,7 @@ game's. One line a frame draws it, and `src/client/main.ts` and
846846
847847 ### page
848848
849 **Weight**: 3.5 kB raw, 1.7 kB gzipped — this module alone.
849 **Weight**: 3.6 kB raw, 1.8 kB gzipped — this module alone.
850850
851851 How you boot your entry in a test with no browser. `fakePage` hands back a
852852 `FakePage`: a `FakePage.surface`, a `FakePage.window` and a `FakePage.document`
@@ -882,17 +882,44 @@ what was drawn reads a list rather than a screen. It is a `Paint`, so anything
882882 you draw through `overlay` draws onto one.
883883
884884 What it deliberately does not know is what a renderer or a sound device is.
885 `FakeCanvas.gl` is an empty slot that `getContext("webgl2")` hands back, and the
886 recorder you put in it is your game's: both games here keep one that writes down
887 every GL call the engine's renderer made. The same goes for an `AudioContext`,
888 for a `window.fetch`, and for installing any of this as a global — a page that
889 knew about those would be one game's page wearing the engine's name. Your game's
890 half lives beside your entry: `src/client/stage.test-helper.ts` and
891 `src/range/stage.test-helper.ts` are the two to copy.
885 `FakeCanvas.gl` is an empty slot that `getContext("webgl2")` hands back, and what
886 you put in it is the engine's too: `fakeGl()`, the renderer's double, which the
887 next section is about. What stays yours is an `AudioContext`, a `window.fetch`,
888 and installing any of this as a global — a page that knew about those would be
889 one game's page wearing the engine's name. Your game's half lives beside your
890 entry: `src/client/stage.test-helper.ts` and `src/range/stage.test-helper.ts`
891 are the two to copy.
892892
893893 It ships in no bundle. Nothing a game bundles imports it, and `page.test.ts`
894894 reads both committed bundles to say so.
895895
896 ### gl
897
898 **Weight**: 1.6 kB raw, 0.8 kB gzipped — this module alone.
899
900 The second thing your tests get: a WebGL2 context for the renderer to draw
901 into, when there is no GPU. `fakeGl` hands back a `GlRecorder` — every enum the
902 renderer names, as a distinct number, and every call it makes, each written
903 down in `GlRecorder.calls` with its arguments, in order. It hands back what GL
904 would where the renderer reads the answer: a resource for each `create…`, kept
905 in `GlRecorder.resources` in the order they were made; true for a compile or
906 link status; an empty info log; and a uniform location that carries the
907 uniform's name, so the `gl.uniformMatrix4fv` that set the view-projection is
908 found by reading its first argument. Put one in your scene canvas's `FakeCanvas.gl` and
909 the renderer your entry builds for itself draws into it; a test then counts the
910 `gl.drawElements` it was asked for.
911
912 It answers exactly the names `renderer.ts` uses and no other — they are written
913 out rather than answered by a Proxy that takes any name, and `gl.test.ts` reads the renderer's
914 source and fails when the two lists part, in either direction. So a renderer
915 that learns a new GL call arrives with one line here, and a typo in the renderer
916 cannot pass a test because the double answered it anyway. It is a list of what
917 was asked for, never a fake GPU: nothing in it checks a pixel.
918
919 Like `fakePage`, it ships in no bundle: nothing a game bundles imports it, and
920 `gl.test.ts` reads every built bundle to say so. Every game here boots on it —
921 the shooter, the range and the example each put one in their scene canvas.
922
896923 ### digest
897924
898925 **Weight**: 1.5 kB raw, 0.9 kB gzipped — this module alone.
@@ -1040,7 +1067,7 @@ rows, an entry aside, and issue #130 is where it gets one.
10401067 | the game supplies | the shooter | the range |
10411068 | --- | --- | --- |
10421069 | an entry | `src/client/main.ts` | `src/range/main.ts` |
1043 | a host | `fakePage` plus the ids, the sound and the recorders in `src/client/stage.test-helper.ts` | `fakePage` plus the ids, the `AudioContext` and the recorder in `src/range/stage.test-helper.ts` |
1070 | a host | `fakePage` and `fakeGl` plus the ids, the sound, the renderer and the posts in `src/client/stage.test-helper.ts` | `fakePage` and `fakeGl` plus the ids and the `AudioContext` in `src/range/stage.test-helper.ts` |
10441071 | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else |
10451072 | a tick rate | 64 Hz | 50 Hz |
10461073 | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate |
src/engine/gl.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * The GL recorder, held to the renderer it doubles for. Its lists of names are
3 * written out in `gl.ts`, and this is what keeps them the renderer's rather than
4 * a copy that drifts: every `gl.<name>` in `renderer.ts` is read out of the
5 * source, and the recorder must answer exactly that set — no name the renderer
6 * calls may be missing, and no name the renderer stopped calling may linger.
7 * It is the test that would have caught the range's copy without `deleteBuffer`.
8 *
9 * The rest says the recorder does what a boot test reads it for, and that no
10 * bundle a browser downloads carries a byte of it.
11 */
12
13 import { expect, test } from "bun:test";
14 import { readdirSync, readFileSync } from "node:fs";
15 import { join, relative } from "node:path";
16 import { builtBundles } from "./bundles.test.ts";
17 import { fakeGl, GL_ASKS, GL_ENUM_NAMES, GL_KINDS, GL_VOID_NAMES, RECORDER } from "./gl.ts";
18
19 const ENGINE = import.meta.dir;
20
21 /** A source with its comments taken out, so a name in a docstring is not a call. */
22 const code = (file: string, dir = ENGINE): string =>
23 readFileSync(join(dir, file), "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
24
25 /** Every member `source` reads off a variable named `gl`, sorted and once each. */
26 function glNames(source: string): string[] {
27 const names = new Set<string>();
28 for (const match of source.matchAll(/\bgl\.([A-Za-z_][A-Za-z0-9_]*)/g)) names.add(match[1] as string);
29 return [...names].sort();
30 }
31
32 /** What the recorder answers, as the renderer would see it: every member but its two records. */
33 const answered = (): string[] =>
34 Object.keys(fakeGl()).filter((name) => name !== "calls" && name !== "resources").sort();
35
36 test("the reader finds a gl member and nothing that only looks like one", () => {
37 expect(glNames("gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.DEPTH_BUFFER_BIT);"))
38 .toEqual(["COLOR_BUFFER_BIT", "DEPTH_BUFFER_BIT", "clear"]);
39 // `gl_Position` is GLSL, `ogl.x` is some other variable, and `gl` alone is the context.
40 expect(glNames("gl_Position = v; ogl.draw(); return { gl };")).toEqual([]);
41 // The renderer is read for real: a reader that found nothing would pass anything.
42 expect(glNames(code("renderer.ts")).length).toBeGreaterThan(50);
43 });
44
45 test("the recorder answers exactly the GL names the renderer uses", () => {
46 const used = glNames(code("renderer.ts"));
47 const missing = used.filter((name) => !answered().includes(name));
48 const stale = answered().filter((name) => !used.includes(name));
49 // Named in the message, so the failure says which line of gl.ts to change.
50 expect({ missing, stale }).toEqual({ missing: [], stale: [] });
51 });
52
53 test("a missing name is what the check sees", () => {
54 // The same comparison as above, on a renderer that grew one call the recorder
55 // does not answer: this is the range's drift, caught.
56 const used = glNames(`${code("renderer.ts")}\ngl.deleteTexture(t);`);
57 expect(used.filter((name) => !answered().includes(name))).toEqual(["deleteTexture"]);
58 });
59
60 test("every enum is a distinct number, and every call a function", () => {
61 const gl = fakeGl();
62 const numbers = GL_ENUM_NAMES.map((name) => gl[name]);
63 for (const n of numbers) expect(typeof n).toBe("number");
64 expect(new Set(numbers).size).toBe(GL_ENUM_NAMES.length);
65 for (const name of [...GL_VOID_NAMES, ...GL_ASKS, ...GL_KINDS.map((kind) => `create${kind}`)])
66 expect(typeof gl[name]).toBe("function");
67 });
68
69 test("every call is recorded with its arguments, and hands back what GL would", () => {
70 const gl = fakeGl();
71 const call = (name: string, ...args: unknown[]): unknown => (gl[name] as (...a: unknown[]) => unknown)(...args);
72 const shader = call("createShader", gl.VERTEX_SHADER);
73 const program = call("createProgram");
74 call("bindBuffer", gl.ARRAY_BUFFER, null);
75 expect(shader).toEqual({ kind: "Shader", id: 1 });
76 expect(program).toEqual({ kind: "Program", id: 2 });
77 expect(gl.resources).toEqual([{ kind: "Shader", id: 1 }, { kind: "Program", id: 2 }]);
78 expect(gl.calls).toEqual([
79 ["createShader", gl.VERTEX_SHADER],
80 ["createProgram"],
81 ["bindBuffer", gl.ARRAY_BUFFER, null],
82 ]);
83 expect(call("getShaderParameter", shader, gl.COMPILE_STATUS)).toBe(true);
84 expect(call("getProgramParameter", program, gl.LINK_STATUS)).toBe(true);
85 expect(call("getShaderInfoLog", shader)).toBe("");
86 expect(call("getProgramInfoLog", program)).toBe("");
87 expect(call("getUniformLocation", program, "uModel")).toEqual({ kind: "uModel", id: 0 });
88 // Two recorders share nothing.
89 expect(fakeGl().calls).toEqual([]);
90 });
91
92 test("no bundle this tree builds carries the recorder", () => {
93 const { dir, bundles } = builtBundles();
94 expect(bundles.length).toBeGreaterThanOrEqual(3);
95 for (const file of bundles) {
96 const bundle = readFileSync(join(dir, file), "utf8");
97 // The reader is alive: every game draws through the renderer.
98 expect(bundle).toContain("webgl2");
99 expect(bundle).not.toContain(RECORDER);
100 }
101 });
102
103 test("nothing a browser runs names the recorder: only tests and the door do", () => {
104 // The sentence above is the proof about bytes; this is the proof about
105 // imports, which a tree-shaker cannot make look better than it is.
106 const src = join(ENGINE, "..");
107 const names = readdirSync(src, { recursive: true, encoding: "utf8" })
108 .filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && !file.endsWith(".test-helper.ts"))
109 .filter((file) => code(file, src).includes("fakeGl"))
110 .map((file) => relative(src, join(src, file)))
111 .sort();
112 expect(names).toEqual(["engine/gl.ts", "engine/index.ts"]);
113 });
src/engine/gl.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The renderer's double: a `WebGL2RenderingContext` that writes down every call
3 * it was asked to make, with its arguments, and does nothing else. It is the
4 * same trick `FakePaint` plays for 2D — what is uploaded, bound and drawn is
5 * checked by reading a list instead of a screen — and it sits behind the door
6 * for the reason `fakePage` does: the engine ships the renderer, so it ships the
7 * context a game's boot test hands that renderer. Before this file there were
8 * four copies of it, one in `renderer.test.ts` and one in each game's helper,
9 * and they had drifted — the range's forgot two calls the shooter's knew.
10 *
11 * The names it answers are written out below rather than answered by a `Proxy`,
12 * and that is the choice this file defends: a `Proxy` answers any name, so a
13 * typo in the renderer or a GL call renamed under it would pass every test on
14 * top of it. Written out, the lists are checked — `gl.test.ts` reads
15 * `renderer.ts` and fails when the renderer names a GL member this does not
16 * answer, or this answers one the renderer no longer names. The lists are the
17 * renderer's, derived from its source by that test; nothing here belongs to a
18 * game.
19 *
20 * It is a list of what was asked for and must not grow into a fake GPU: an enum
21 * is a distinct number so a recorded call reads back, a `create…` hands back a
22 * plain resource, a compile or a link succeeds with an empty log, and a uniform
23 * location is an object carrying the uniform's name. Nothing here checks what a
24 * pixel came out, only that the right work was asked for.
25 *
26 * It ships in no bundle: nothing a game bundles imports it, and `gl.test.ts`
27 * reads every built bundle for the sentence below to say so.
28 */
29
30 /**
31 * A sentence that appears in this file and in no other. A minifier keeps the
32 * bytes of a string, so `gl.test.ts` reads the built bundles for this one: a
33 * double that ever ends up in a file a browser downloads fails there.
34 */
35 export const RECORDER = "src/engine/gl.ts: a double of a GL context, in no bundle";
36
37 /** Every GL enum `renderer.ts` names. Numbered from 1, in this order, so no two are equal. */
38 export const GL_ENUM_NAMES = [
39 "ARRAY_BUFFER", "BACK", "BLEND", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE",
40 "DEPTH_BUFFER_BIT", "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT",
41 "FRAGMENT_SHADER", "LINEAR", "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS",
42 "ONE_MINUS_SRC_ALPHA", "POLYGON_OFFSET_FILL", "REPEAT", "RGBA", "RGBA8", "SRC_ALPHA",
43 "STATIC_DRAW", "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER",
44 "TEXTURE_WRAP_S", "TEXTURE_WRAP_T", "TRIANGLES", "TRIANGLE_STRIP", "UNSIGNED_BYTE",
45 "UNSIGNED_INT", "VERTEX_SHADER",
46 ] as const;
47
48 /** Every GL call `renderer.ts` makes whose answer it ignores. Each is recorded and returns nothing. */
49 export const GL_VOID_NAMES = [
50 "activeTexture", "attachShader", "bindBuffer", "bindTexture", "bindVertexArray", "blendFunc",
51 "bufferData", "bufferSubData", "clear", "clearColor", "compileShader", "cullFace",
52 "deleteBuffer", "deleteVertexArray", "depthMask", "disable", "drawArrays", "drawElements",
53 "enable", "enableVertexAttribArray", "generateMipmap", "linkProgram", "polygonOffset",
54 "shaderSource", "texImage3D", "texParameteri", "uniform1f", "uniform1i", "uniform3f",
55 "uniform3fv", "uniformMatrix4fv", "useProgram", "vertexAttribPointer", "viewport",
56 ] as const;
57
58 /** Every GL object `renderer.ts` creates: `create` + the kind is the call. */
59 export const GL_KINDS = ["Buffer", "Program", "Shader", "Texture", "VertexArray"] as const;
60
61 /** Every GL call whose answer `renderer.ts` reads, beyond a `create…`. */
62 export const GL_ASKS = [
63 "getProgramInfoLog", "getProgramParameter", "getShaderInfoLog", "getShaderParameter",
64 "getUniformLocation",
65 ] as const;
66
67 /**
68 * A resource the recorder handed out: what kind, and which one. A uniform
69 * location is one too, with the uniform's name as its kind and id 0, so a test
70 * finds the `uniformMatrix4fv` that set `uViewProj` by reading its first argument.
71 */
72 type Resource = { kind: string; id: number };
73
74 /** The recorder: the context the renderer sees, plus what it was asked for. */
75 export type GlRecorder = Record<string, unknown> & {
76 /** Every call made, in order: the name, then the arguments it was made with. */
77 calls: [string, ...unknown[]][];
78 /** Every resource a `create…` handed out, in the order it handed them out. */
79 resources: Resource[];
80 };
81
82 /** A fresh recorder: no call made and no resource handed out. */
83 export function fakeGl(): GlRecorder {
84 const calls: [string, ...unknown[]][] = [];
85 const resources: Resource[] = [];
86 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
87 const hand = (kind: string) => (...args: unknown[]): Resource => {
88 calls.push([`create${kind}`, ...args]);
89 const made = { kind, id: resources.length + 1 };
90 resources.push(made);
91 return made;
92 };
93 return {
94 calls,
95 resources,
96 ...Object.fromEntries(GL_ENUM_NAMES.map((name, i) => [name, i + 1])),
97 ...Object.fromEntries(GL_VOID_NAMES.map((name) => [name, note(name)])),
98 ...Object.fromEntries(GL_KINDS.map((kind) => [`create${kind}`, hand(kind)])),
99 getUniformLocation: (_program: unknown, name: string): Resource => ({ kind: name, id: 0 }),
100 getShaderParameter: (): boolean => true,
101 getProgramParameter: (): boolean => true,
102 getShaderInfoLog: (): string => "",
103 getProgramInfoLog: (): string => "",
104 };
105 }
src/engine/index.ts +11
@@ -142,6 +142,17 @@ export type {
142142 PageOptions,
143143 } from "./page.ts";
144144
145 /**
146 * The renderer's double: a WebGL2 context that answers every name the renderer
147 * uses and writes down every call it was asked to make. The engine ships the
148 * renderer, so it ships the context a boot test hands one — it was four copies,
149 * one per game and one in the renderer's own test, and they had drifted.
150 * `gl.test.ts` derives its names from `renderer.ts`, and like `fakePage` it
151 * ships in no bundle. It is a list of what was asked for, not a fake GPU.
152 */
153 export { fakeGl } from "./gl.ts";
154 export type { GlRecorder } from "./gl.ts";
155
145156 /**
146157 * A run folded into a hash: the quantiser, the fold with its checkpoints, and
147158 * the complaint that says where two runs parted. Like `fakePage`, it is a tool
src/engine/page.ts +4 −4
@@ -21,8 +21,8 @@
2121 * them fires the event a page hears it from. It is honest
2222 * about being a double: it has no timer, it touches no global, it throws for
2323 * nothing, and it knows what neither a renderer nor an `AudioContext` is —
24 * `canvas.gl` is a slot a game fills with its own recorder, because what a
25 * WebGL2 context means is the game's business and not this file's.
24 * `canvas.gl` is an empty slot, and a game that boots its renderer fills it
25 * with `fakeGl` from `gl.ts`, the renderer's own double. The page stays a page.
2626 *
2727 * What it does not have is what belongs to one game: its ids, its buttons' names,
2828 * its sounds, its posts. Those arrive as `PageOptions` or stay in the game's own
@@ -219,8 +219,8 @@ export type FakeCanvas = FakeElement & {
219219 /**
220220 * What `getContext("webgl2")` answers with. It starts null — a browser with
221221 * no WebGL2, which is a browser a game has to keep working on — and a game
222 * puts its own recorder here, because this file does not know what a GL
223 * context is and should not.
222 * puts `fakeGl()` here, because the recorder is the renderer's double and
223 * this file does not know what a GL context is and should not.
224224 */
225225 gl: unknown;
226226 /** How many times something asked for the pointer. */
src/engine/renderer.test.ts +20 −69
@@ -1,17 +1,17 @@
11 /**
2 * The renderer, driven with no GPU and no browser. A stub context writes down
3 * every call it was asked to make — the same trick `overlay.test.ts` plays with
4 * a `Paint` for 2D — so what is uploaded, what is bound and what is drawn is
5 * checked by reading a list instead of a screen.
2 * The renderer, driven with no GPU and no browser. `fakeGl` from `gl.ts` writes
3 * down every call it was asked to make — the same trick `overlay.test.ts` plays
4 * with a `Paint` for 2D — so what is uploaded, what is bound and what is drawn
5 * is checked by reading a list instead of a screen.
66 *
7 * The stub is deliberately only as wide as this module: the enums it names and
8 * the functions it calls, each recording its arguments and handing back a plain
9 * object where GL would hand back a resource. It is not a fake GPU and must not
10 * grow into one — nothing here checks what a pixel came out, only that the
11 * right work was asked for once.
7 * It is the same recorder every game's boot test hands this renderer, and it is
8 * only as wide as this module: `gl.test.ts` holds its names to the ones
9 * `renderer.ts` uses. It is not a fake GPU — nothing here checks what a pixel
10 * came out, only that the right work was asked for once.
1211 */
1312
1413 import { expect, test } from "bun:test";
14 import { fakeGl, type GlRecorder } from "./gl.ts";
1515 import { createRenderer } from "./renderer.ts";
1616 // `Scene` is scene.ts's, and renderer.ts only imports it: a type a module
1717 // imports is not a type it exports.
@@ -19,63 +19,14 @@ import type { Scene } from "./scene.ts";
1919 import { addBox, builder, finish, type Mesh } from "./mesh.ts";
2020 import { multiply, perspective, viewMatrix } from "./math.ts";
2121
22 type Call = [string, ...unknown[]];
22 /** One recorded call: its name, then its arguments. */
23 type Call = GlRecorder["calls"][number];
2324
24 /** Every GL enum this module names. Numbered so a recorded call reads back. */
25 const ENUMS = [
26 "ARRAY_BUFFER", "BACK", "BLEND", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE",
27 "DEPTH_BUFFER_BIT", "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT",
28 "FRAGMENT_SHADER", "LINEAR", "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS",
29 "ONE_MINUS_SRC_ALPHA", "POLYGON_OFFSET_FILL", "REPEAT", "RGBA", "RGBA8", "SRC_ALPHA",
30 "STATIC_DRAW", "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER",
31 "TEXTURE_WRAP_S", "TEXTURE_WRAP_T", "TRIANGLES", "TRIANGLE_STRIP", "UNSIGNED_BYTE",
32 "UNSIGNED_INT", "VERTEX_SHADER",
33 ] as const;
34
35 /** A resource the stub handed out, in the order it handed them out. */
25 /** A resource the recorder handed out: a `create…`'s answer, or a uniform location. */
3626 type Resource = { kind: string; id: number };
3727
38 type StubGl = Record<string, unknown> & { calls: Call[]; resources: Resource[] };
39
40 /** A `WebGL2RenderingContext` that records what it was told to do, and nothing else. */
41 function stubGl(): StubGl {
42 const calls: Call[] = [];
43 const resources: Resource[] = [];
44 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
45 const hand = (kind: string) => (...args: unknown[]): Resource => {
46 calls.push([kind === "shader" ? "createShader" : `create${kind}`, ...args]);
47 const made = { kind, id: resources.length + 1 };
48 resources.push(made);
49 return made;
50 };
51 const gl: StubGl = {
52 calls,
53 resources,
54 ...Object.fromEntries(ENUMS.map((name, i) => [name, i + 1])),
55 createShader: hand("shader"),
56 createProgram: hand("Program"),
57 createTexture: hand("Texture"),
58 createBuffer: hand("Buffer"),
59 createVertexArray: hand("VertexArray"),
60 getUniformLocation: (_program: unknown, name: string): Resource => ({ kind: name, id: 0 }),
61 getShaderParameter: (): boolean => true,
62 getProgramParameter: (): boolean => true,
63 getShaderInfoLog: (): string => "",
64 getProgramInfoLog: (): string => "",
65 };
66 for (const name of [
67 "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D",
68 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData",
69 "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "disable",
70 "cullFace", "blendFunc", "polygonOffset", "depthMask", "viewport", "clearColor", "clear",
71 "useProgram", "activeTexture", "uniform1i", "uniform1f", "uniform3f", "uniform3fv",
72 "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray", "deleteBuffer",
73 ]) gl[name] = note(name);
74 return gl;
75 }
76
7728 /** A canvas as far as the renderer is concerned: a size, and one context. */
78 function stubCanvas(gl: StubGl | null) {
29 function stubCanvas(gl: GlRecorder | null) {
7930 return {
8031 clientWidth: 800,
8132 clientHeight: 600,
@@ -105,7 +56,7 @@ const atlas = (size = TILE, layers = 2) => ({
10556
10657 /** A renderer on a stub context, with the meshes a test wants by name. */
10758 function renderer(meshes: Record<string, Mesh> = { level: box(), player: box(6) }) {
108 const gl = stubGl();
59 const gl = fakeGl();
10960 const canvas = stubCanvas(gl);
11061 const r = createRenderer(canvas as unknown as HTMLCanvasElement, {
11162 atlas: atlas(),
@@ -143,13 +94,13 @@ function scene(mesh = "level"): Scene {
14394 * so the two numbers are the game's and the engine only defaults them.
14495 */
14596 test("the projection clips where the game's units say, not where the shooter's do", () => {
146 const gl = stubGl();
97 const gl = fakeGl();
14798 const canvas = stubCanvas(gl);
14899 const metres = createRenderer(canvas as unknown as HTMLCanvasElement, {
149100 atlas: atlas(), meshes: { level: box() }, devicePixelRatio: () => 1, near: 0.05, far: 60,
150101 });
151102 metres?.draw(scene(), 1 / 60);
152 const sent = (g: StubGl): Float32Array =>
103 const sent = (g: GlRecorder): Float32Array =>
153104 (g.calls.find((c) => c[0] === "uniformMatrix4fv" && (c[1] as { kind: string }).kind === "uViewProj")
154105 ?.[3]) as Float32Array;
155106 const aspect = canvas.width / canvas.height;
@@ -166,7 +117,7 @@ test("the projection clips where the game's units say, not where the shooter's d
166117 });
167118
168119 /** Every recorded call of this name, arguments and all. */
169 const of = (gl: StubGl, name: string): Call[] => gl.calls.filter((c) => c[0] === name);
120 const of = (gl: GlRecorder, name: string): Call[] => gl.calls.filter((c) => c[0] === name);
170121
171122 /** Only the calls made after construction: what one frame asked for. */
172123 const frameCalls = (r: ReturnType<typeof renderer>, name: string): Call[] =>
@@ -286,7 +237,7 @@ test("a mesh name nobody handed in throws, and says which name", () => {
286237
287238 test("an atlas whose bytes are the wrong length is refused at construction", () => {
288239 const short = { bytes: new Uint8Array(10), size: TILE, layers: 2 };
289 const gl = stubGl();
240 const gl = fakeGl();
290241 expect(() =>
291242 createRenderer(stubCanvas(gl) as unknown as HTMLCanvasElement, {
292243 atlas: short,
@@ -301,7 +252,7 @@ test("an atlas whose bytes are the wrong length is refused at construction", ()
301252 { bytes: new Uint8Array(64), size: 4.5, layers: 1 },
302253 ])
303254 expect(() =>
304 createRenderer(stubCanvas(stubGl()) as unknown as HTMLCanvasElement, {
255 createRenderer(stubCanvas(fakeGl()) as unknown as HTMLCanvasElement, {
305256 atlas: bad,
306257 meshes: { level: box() },
307258 }),
@@ -321,7 +272,7 @@ test("the frame clears to the scene's sky, or the renderer's when it names none"
321272 });
322273
323274 test("the canvas is sized in device pixels, and the viewport with it", () => {
324 const gl = stubGl();
275 const gl = fakeGl();
325276 const canvas = stubCanvas(gl);
326277 let ratio = 2;
327278 const r = createRenderer(canvas as unknown as HTMLCanvasElement, {
src/engine/surface.json +3 −1
@@ -1,5 +1,5 @@
11 {
2 "version": "0.8.0",
2 "version": "0.9.0",
33 "names": [
44 "Activations",
55 "Adam",
@@ -35,6 +35,7 @@
3535 "FullscreenHost",
3636 "FullscreenOwner",
3737 "FullscreenTarget",
38 "GlRecorder",
3839 "Grads",
3940 "Grid",
4041 "Hit",
@@ -127,6 +128,7 @@
127128 "effectAt",
128129 "expireEffects",
129130 "eyeHeight",
131 "fakeGl",
130132 "fakePage",
131133 "fbm",
132134 "findPath",
src/example/stage.test-helper.ts +8 −44
@@ -11,13 +11,12 @@
1111 * would throw here rather than pass.
1212 *
1313 * What it does have, since #216, is a recorder for the GL calls the engine's
14 * renderer makes — the same idea as the range's, written the short way. Both
15 * other games write theirs out call by call; this one answers every name it is
16 * asked for, because the list of names is the renderer's and not this game's,
17 * and a third copy of that list is a third thing to keep in step. It is not a
18 * fake GPU: it records what was asked for and checks no pixel. It stays here
19 * rather than behind the door for the reason the range's does — a page that
20 * knew what a renderer was would be this game's page wearing the engine's name.
14 * renderer makes, and it is not this game's: it is the engine's `fakeGl`, the
15 * renderer's double beside the renderer. The list of names it answers is the
16 * renderer's, so it is kept in one place and `src/engine/gl.test.ts` derives it
17 * from `renderer.ts` — where this file used to answer any name at all, which
18 * needed no upkeep and would have waved a typo through. It is not a fake GPU:
19 * it records what was asked for and checks no pixel.
2120 *
2221 * It ships in no bundle, and only tests import it: `boot.test.ts`,
2322 * `bundle.test.ts`, `src/server.test.ts` for the ids and actions the served
@@ -26,42 +25,7 @@
2625 * reads `BINDINGS` without booting anything.
2726 */
2827
29 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
30
31 /** Every GL call made, in order, and the arguments it was made with. */
32 export type GlRecorder = { calls: [string, ...unknown[]][] };
33
34 /**
35 * A `WebGL2RenderingContext` that writes down what it was told to do. A SHOUTED
36 * name is an enum and answers a distinct number, so a recorded call reads back;
37 * anything else is a function that notes the call and hands back what GL would:
38 * a resource for a `create…`, true for a compile or link, an empty log, a
39 * location for a uniform.
40 */
41 export function glRecorder(): GlRecorder {
42 const calls: [string, ...unknown[]][] = [];
43 const enums = new Map<string, number>();
44 let made = 0;
45 const answer = (name: string): unknown =>
46 name.startsWith("create") ? { kind: name, id: (made += 1) }
47 : name.endsWith("Parameter") ? true
48 : name.endsWith("InfoLog") ? ""
49 : name === "getUniformLocation" ? { kind: "uniform", id: 0 }
50 : undefined;
51 return new Proxy({ calls } as GlRecorder & Record<string, unknown>, {
52 get(target, key): unknown {
53 if (typeof key !== "string" || key === "calls") return Reflect.get(target, key);
54 if (/^[A-Z][A-Z0-9_]*$/.test(key)) {
55 if (!enums.has(key)) enums.set(key, enums.size + 1);
56 return enums.get(key);
57 }
58 return (...args: unknown[]): unknown => {
59 calls.push([key, ...args]);
60 return answer(key);
61 };
62 },
63 });
64 }
28 import { fakeGl, fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow, type GlRecorder } from "../engine/index.ts";
6529
6630 /** Every id `src/pages/example.ts` puts on the page that the entry looks up. */
6731 export const EXAMPLE_IDS = ["stage", "scene", "hud", "fps", "touch", "note", "full"];
@@ -108,7 +72,7 @@ export function page(
10872 fullscreen: options.fullscreen,
10973 });
11074 const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas];
111 const gl = options.webgl === false ? null : glRecorder();
75 const gl = options.webgl === false ? null : fakeGl();
11276 scene.gl = gl;
11377
11478 return {
src/range/boot.test.ts +2 −1
@@ -13,7 +13,8 @@
1313
1414 import { expect, test } from "bun:test";
1515 import { HIT, LANE_PAN, POP, TICK } from "./audio.ts";
16 import { page, type GlRecorder, type RangePage, type Voice } from "./stage.test-helper.ts";
16 import type { GlRecorder } from "../engine/index.ts";
17 import { page, type RangePage, type Voice } from "./stage.test-helper.ts";
1718
1819 // The entry boots itself when it is imported, so it is imported into a page
1920 // that is still loading: then every test below boots a fresh one on purpose.
src/range/bundle.test.ts +2 −1
@@ -18,7 +18,8 @@ import { afterAll, expect, test } from "bun:test";
1818 import { readFileSync } from "node:fs";
1919 import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
2020 import { RANGE_MOVE } from "./movement.ts";
21 import { page, type GlRecorder, type RangePage } from "./stage.test-helper.ts";
21 import type { GlRecorder } from "../engine/index.ts";
22 import { page, type RangePage } from "./stage.test-helper.ts";
2223
2324 afterAll(discardBundleCopies);
2425
src/range/scene.test.ts +13
@@ -246,3 +246,16 @@ test("a plate that slid out of the lane is not drawn any more", () => {
246246 expect(run.targets).not.toContain(plateOne);
247247 expect(live(buildRangeScene(run, VIEW, null, 4, scene)).map((i) => i.mesh)).not.toContain(MESH.PLATE);
248248 });
249
250 test("every drill's highest plate still casts its shadow on the floor", () => {
251 // The reach is the range's number and the heights are each drill's: a plate
252 // hung as high as its drill allows is still a plate standing on the lane, and
253 // a frame that drew it with no shadow was the flake in `boot.test.ts` that one
254 // clock seed in sixty turned up.
255 for (const drill of Object.values(DRILLS)) {
256 const top = plate(1, [800, 0, drill.height.max], drill.half);
257 const scene = buildRangeScene(standing([top]), VIEW, null, 0, createRangeScene());
258 expect(scene.shadowCount).toBe(1);
259 expect(scene.shadows[0]?.strength).toBeGreaterThan(0);
260 }
261 });
src/range/scene.ts +9 −6
@@ -69,13 +69,16 @@ export const SHADOW_CAPACITY = INSTANCE_CAPACITY;
6969
7070 /**
7171 * How far above the floor a plate still darkens it, in units. `STANDARD` hangs
72 * its plates with their feet about forty units up, so a standing plate's shadow
73 * is a little under half as dark as one resting on the floor would be and a
74 * third again as wide — which is what says it is hanging. A drill that hung one
75 * over 128 units up would have it cast nothing, and that is the right answer:
76 * nothing that high reads as standing on the lane.
72 * its plates with their feet anywhere from ten to a hundred and thirty units up
73 * — its centres run 28 to 148 and a plate is 36 across — so the reach has to
74 * clear the highest of them: at 128 one plate in sixty stood with no shadow
75 * under it, which read as a plate that was not there. At 160 a plate forty up
76 * casts a shadow a little over half as dark as one resting on the floor would,
77 * and a quarter again as wide, which is what says it is hanging; the highest
78 * casts a faint one. A drill that hung a plate over 160 units up would have it
79 * cast nothing, and `scene.test.ts` fails when a drill in the table does.
7780 */
78 export const SHADOW_REACH = 128;
81 export const SHADOW_REACH = 160;
7982
8083 /** How dark a plate's shadow is where it touches, and how wide beside the plate. */
8184 const PLATE_DARK = 0.55;
src/range/stage.test-helper.ts +11 −64
@@ -3,15 +3,20 @@
33 * `fakePage` behind the door hands back the document, the window, the elements,
44 * the 2D recorder and the clock, because both games needed exactly that and
55 * neither may import the other's copy of it. What is left here is what is
6 * genuinely this game's: the ids `src/pages/range.ts` renders, a recorder for
7 * every GL call the engine's renderer makes, a counter for the `AudioContext`
8 * the engine's sound device opens, and the line that installs all of it as the
9 * globals `main.ts` reads for itself.
6 * genuinely this game's: the ids `src/pages/range.ts` renders, a counter for
7 * the `AudioContext` the engine's sound device opens, and the line that
8 * installs all of it as the globals `main.ts` reads for itself.
9 *
10 * The recorder the scene canvas answers `getContext("webgl2")` with is the
11 * engine's `fakeGl` too: it is the renderer's double, so it sits behind the
12 * door beside the renderer, and `src/engine/gl.test.ts` keeps its names the
13 * renderer's. A test here reads it to see that the room and the plates were
14 * drawn; nothing checks a pixel.
1015 *
1116 * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it.
1217 */
1318
14 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
19 import { fakeGl, fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
1520
1621 /** Every id `src/pages/range.ts` puts on the page that the entry looks up. */
1722 export const RANGE_IDS = ["stage", "scene", "hud", "note", "touch", "look", "fps", "full", "name"];
@@ -19,64 +24,6 @@ export const RANGE_IDS = ["stage", "scene", "hud", "note", "touch", "look", "fps
1924 /** The `[data-action]` buttons the shell renders for this game. */
2025 export const RANGE_ACTIONS = ["duck", "jump", "fire"];
2126
22 /**
23 * Every GL call the engine's renderer makes, written down in order, and every
24 * enum it names — numbered, so a recorded call reads back. It is the same trick
25 * the engine's `FakePaint` plays for 2D and `renderer.test.ts` plays inside the
26 * engine: not a fake GPU, only a list of what was asked for. A test reads it to
27 * see that the room and the plates were drawn; nothing here checks a pixel.
28 *
29 * It stays here rather than behind the door because a page that knew what a
30 * renderer was would be this game's page wearing the engine's name: the engine's
31 * canvas has an empty `gl` slot, and this is what the range puts in it.
32 */
33 export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] };
34
35 const GL_ENUMS = [
36 "ARRAY_BUFFER", "BACK", "BLEND", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE",
37 "DEPTH_BUFFER_BIT", "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT",
38 "FRAGMENT_SHADER", "LINEAR", "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS",
39 "ONE_MINUS_SRC_ALPHA", "POLYGON_OFFSET_FILL", "REPEAT", "RGBA", "RGBA8", "SRC_ALPHA",
40 "STATIC_DRAW", "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER",
41 "TEXTURE_WRAP_S", "TEXTURE_WRAP_T", "TRIANGLES", "TRIANGLE_STRIP", "UNSIGNED_BYTE",
42 "UNSIGNED_INT", "VERTEX_SHADER",
43 ];
44
45 const GL_VOIDS = [
46 "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D",
47 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData",
48 "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "disable",
49 "cullFace", "blendFunc", "polygonOffset", "depthMask", "viewport", "clearColor", "clear",
50 "useProgram", "activeTexture", "uniform1i", "uniform1f", "uniform3f", "uniform3fv",
51 "uniformMatrix4fv", "drawElements", "drawArrays",
52 ];
53
54 export function glRecorder(): GlRecorder {
55 const calls: [string, ...unknown[]][] = [];
56 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
57 let made = 0;
58 const hand = (kind: string) => (...args: unknown[]): { kind: string; id: number } => {
59 calls.push([kind === "shader" ? "createShader" : `create${kind}`, ...args]);
60 return { kind, id: (made += 1) };
61 };
62 const gl: GlRecorder = {
63 calls,
64 ...Object.fromEntries(GL_ENUMS.map((name, i) => [name, i + 1])),
65 ...Object.fromEntries(GL_VOIDS.map((name) => [name, note(name)])),
66 createShader: hand("shader"),
67 createProgram: hand("Program"),
68 createTexture: hand("Texture"),
69 createBuffer: hand("Buffer"),
70 createVertexArray: hand("VertexArray"),
71 getUniformLocation: (_program: unknown, name: string) => ({ kind: name, id: 0 }),
72 getShaderParameter: () => true,
73 getProgramParameter: () => true,
74 getShaderInfoLog: () => "",
75 getProgramInfoLog: () => "",
76 };
77 return gl;
78 }
79
8027 /**
8128 * The page's audio, counted. What a test wants to know about sound here is not
8229 * what it sounded like — `audio.test.ts` reads every number of that — but how
@@ -262,7 +209,7 @@ export function page(
262209 fullscreen: options.fullscreen,
263210 });
264211 const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas];
265 if (options.webgl === true) scene.gl = glRecorder();
212 if (options.webgl === true) scene.gl = fakeGl();
266213 const audio = audioCounter();
267214 const net: FakeFetch = { calls: [], reply: { ok: true, status: 200 }, fails: false };
268215 const fetching = (url: string, init: { body?: string }): Promise<{ ok: boolean; status: number }> => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs