@@ -110,7 +110,7 @@
PAUSED
PROTOTYPE PROVENANCE
- Original software renderer, fixed-point flight core, procedural geometry, vector craft, and synthesized audio. Runs directly from local files.
+ Native Three.js renderer, fixed-point flight core, procedural geometry, vector craft, and synthesized audio. Runs directly from local files.
@@ -131,9 +131,9 @@
PAUSED
-
-
+
+
diff --git a/VECTOR_ZERO/renderer.js b/VECTOR_ZERO/renderer.js
new file mode 100644
index 0000000..060c39e
--- /dev/null
+++ b/VECTOR_ZERO/renderer.js
@@ -0,0 +1,292 @@
+(function (root) {
+ "use strict";
+ const T = root.THREE;
+ class VectorRenderer {
+ constructor(canvas, core) {
+ this.core = core;
+ this.stage = new root.QsolNative.Stage(canvas, {
+ width: 480,
+ height: 270,
+ perspective: true,
+ fov: (2 * Math.atan(135 / 310) * 180) / Math.PI,
+ });
+ this.stage.scene.fog = new T.Fog("#071310", 18, 65);
+ this.signature = "";
+ this.mine = null;
+ this.edges = null;
+ this.target = new T.Vector3();
+ this.projected = new T.Vector3();
+ this.cockpitPoint = new T.Vector3();
+ this.map = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ this.map.classList.add("native-map");
+ canvas.parentElement.appendChild(this.map);
+ this.stage.ownedNodes.push(this.map);
+ const reticle = document.createElement("span");
+ reticle.className = "native-reticle";
+ reticle.textContent = "+";
+ canvas.parentElement.appendChild(reticle);
+ this.stage.ownedNodes.push(reticle);
+ }
+ build(state, faces, palette) {
+ if (this.signature === state.blueprint.signature) return;
+ this.signature = state.blueprint.signature;
+ for (const mesh of [this.mine, this.edges])
+ if (mesh) {
+ mesh.removeFromParent();
+ mesh.geometry.dispose();
+ mesh.material.dispose();
+ }
+ const vertices = [],
+ colors = [],
+ color = new T.Color(palette.wall),
+ fp = this.core.FP;
+ for (const face of faces)
+ for (const i of [0, 1, 2, 0, 2, 3]) {
+ const v = face.vertices[i];
+ vertices.push(v.x / fp, v.y / fp, v.z / fp);
+ colors.push(
+ color.r * face.light,
+ color.g * face.light,
+ color.b * face.light,
+ );
+ }
+ const geometry = new T.BufferGeometry();
+ geometry.setAttribute(
+ "position",
+ new T.Float32BufferAttribute(vertices, 3),
+ );
+ geometry.setAttribute("color", new T.Float32BufferAttribute(colors, 3));
+ geometry.computeVertexNormals();
+ this.mine = new T.Mesh(
+ geometry,
+ new T.MeshStandardMaterial({
+ vertexColors: true,
+ side: T.DoubleSide,
+ roughness: 0.85,
+ }),
+ );
+ this.mine.name = "mine-surfaces";
+ this.mine.userData.ownedGeometry = true;
+ this.edges = new T.LineSegments(
+ new T.EdgesGeometry(geometry),
+ new T.LineBasicMaterial({ color: palette.edge }),
+ );
+ this.edges.name = "mine-seams";
+ this.edges.userData.ownedGeometry = true;
+ this.stage.group("terrain").add(this.mine, this.edges);
+ }
+ render(state, faces, ui = {}) {
+ const s = this.stage,
+ c = this.core;
+ s.begin();
+ this.map.style.display = ui.automap && state ? "block" : "none";
+ if (!state) {
+ s.shakeView(0, 0);
+ s.screenFlash("#972d21", 0);
+ s.text("VECTOR ZERO FLIGHT COMPUTER // READY");
+ s.finish();
+ return;
+ }
+ this.build(state, faces, ui.palette);
+ const p = state.player,
+ b = c.getBasis(p.yaw, p.pitch, p.roll),
+ fp = c.FP,
+ hurt = Math.max(0, Number(ui.hurt) || 0),
+ flash = Math.max(0, Number(ui.flash) || 0);
+ s.shakeView(ui.shakeX, ui.shakeY);
+ s.screenFlash("#972d21", Math.max(hurt / 50, flash / 30));
+ s.camera.position.set(p.x / fp, p.y / fp, p.z / fp);
+ s.camera.up.set(b.up.x, b.up.y, b.up.z).normalize();
+ this.target
+ .set(b.forward.x, b.forward.y, b.forward.z)
+ .normalize()
+ .add(s.camera.position);
+ s.camera.lookAt(this.target);
+ s.camera.updateMatrixWorld();
+ let targetLock = null;
+ s.scene.background.set(ui.palette.fog);
+ s.scene.fog.color.set(ui.palette.fog);
+ for (const e of state.enemies) {
+ const r =
+ (e.kind === "custodian" ? 560 : e.kind === "sentinel" ? 430 : 360) /
+ fp,
+ x = e.x / fp,
+ y = e.y / fp,
+ z = e.z / fp;
+ this.projected.set(x, y, z).project(s.camera);
+ if (
+ this.projected.z > -1 &&
+ this.projected.z < 1 &&
+ Math.hypot(this.projected.x, this.projected.y) < 0.11
+ )
+ targetLock = e;
+ const color = e.pain
+ ? "#ffe0a2"
+ : {
+ drone: "#8aa698",
+ hunter: "#b98252",
+ sentinel: "#9c6255",
+ custodian: "#d28c55",
+ }[e.kind];
+ s.item(
+ "enemies",
+ "diamond",
+ x,
+ y,
+ z,
+ r,
+ r * 0.7,
+ r,
+ color,
+ 0,
+ s.reducedMotion ? 0 : state.tick * 0.013,
+ e.phase || 0,
+ );
+ s.item(
+ "enemy-cages",
+ e.kind === "custodian" ? "sphere" : "box",
+ x,
+ y,
+ z,
+ r * 2,
+ r * 1.5,
+ r * 2,
+ color,
+ 0,
+ s.reducedMotion ? 0 : -state.tick * 0.009,
+ 0,
+ "wire",
+ );
+ s.item(
+ "enemies",
+ "sphere",
+ x,
+ y,
+ z,
+ r * 0.35,
+ r * 0.35,
+ r * 0.35,
+ "#d96a4a",
+ 0,
+ 0,
+ 0,
+ "glow",
+ );
+ }
+ const colors = {
+ core: "#e7ad62",
+ energy: "#74b9ad",
+ shield: "#aec39b",
+ missiles: "#c87955",
+ };
+ for (const e of state.pickups) {
+ const r = (e.kind === "core" ? 235 : 170) / fp;
+ s.item(
+ "pickups",
+ e.kind === "core" ? "diamond" : "ring",
+ e.x / fp,
+ e.y / fp,
+ e.z / fp,
+ r,
+ r,
+ r,
+ colors[e.kind],
+ 0,
+ s.reducedMotion ? 0 : state.tick * 0.018,
+ );
+ }
+ const exit = state.blueprint.exit,
+ open = state.coresCollected >= state.coresRequired;
+ for (let i = 0; i < 3; i++)
+ s.item(
+ "exit",
+ "ring",
+ exit.x / fp,
+ exit.y / fp,
+ exit.z / fp,
+ (510 + i * 90) / fp,
+ (510 + i * 90) / fp,
+ 1,
+ open ? "#83c8b3" : "#b25a45",
+ i * 0.65,
+ i * 0.5,
+ 0,
+ "glow",
+ );
+ for (const e of state.projectiles)
+ s.item(
+ "projectiles",
+ "sphere",
+ e.x / fp,
+ e.y / fp,
+ e.z / fp,
+ e.kind === "missile" ? 0.13 : 0.07,
+ 0.07,
+ 0.07,
+ e.kind === "laser"
+ ? "#f4d477"
+ : e.kind === "missile"
+ ? "#e77a52"
+ : "#73b8a8",
+ 0,
+ 0,
+ 0,
+ "glow",
+ );
+ // A real cockpit frame moves with all six camera axes.
+ s.camera.updateMatrixWorld();
+ for (const side of [-1, 1]) {
+ const point = this.cockpitPoint
+ .set(side * 0.12, -0.08, -0.3)
+ .applyMatrix4(s.camera.matrixWorld);
+ const mesh = s.item(
+ "player",
+ "box",
+ point.x,
+ point.y,
+ point.z,
+ 0.025,
+ 0.02,
+ 0.18,
+ "#425e54",
+ );
+ const batch = [...s.batches.values()].find((b) => b.mesh === mesh);
+ this.stage.scratch.quaternion.copy(s.camera.quaternion);
+ this.stage.scratch.updateMatrix();
+ mesh.setMatrixAt(batch.used - 1, this.stage.scratch.matrix);
+ }
+ s.text(
+ `${targetLock ? "FIRE SOLUTION // " + c.ENEMY_TYPES[targetLock.kind].name + " " + targetLock.health + "\n" : ""}MINE ${state.sector} // CORES ${state.coresCollected}/${state.coresRequired} // SCORE ${state.score}\nSHIELD ${p.shield} ENERGY ${p.energy} MISSILES ${p.missiles} HOSTILES ${state.enemies.length}${ui.notice ? "\n" + ui.notice : ""}`,
+ );
+ if (ui.automap) {
+ const current = c.worldCell(p),
+ bp = state.blueprint;
+ this.map.setAttribute("viewBox", `0 0 ${bp.width} ${bp.depth}`);
+ let html = "";
+ for (const cell of bp.cells)
+ if (cell.y === current.y)
+ html += `
`;
+ const pickupColors = {
+ core: "#e7ad62",
+ energy: "#74b9ad",
+ shield: "#aec39b",
+ missiles: "#c87955",
+ };
+ for (const e of state.pickups) {
+ const cell = c.worldCell(e);
+ if (cell.y === current.y)
+ html += `
`;
+ }
+ for (const e of state.enemies) {
+ const cell = c.worldCell(e);
+ if (cell.y === current.y)
+ html += `
`;
+ }
+ html += `
`;
+ this.map.innerHTML = html;
+ }
+ s.finish();
+ }
+ }
+ root.VectorRenderer = VectorRenderer;
+})(window);
diff --git a/VECTOR_ZERO/tests/browser-smoke.js b/VECTOR_ZERO/tests/browser-smoke.js
index 06f2a50..7c5c8e3 100644
--- a/VECTOR_ZERO/tests/browser-smoke.js
+++ b/VECTOR_ZERO/tests/browser-smoke.js
@@ -9,8 +9,8 @@ const core = require("../core.js");
(async () => {
const browser = await chromium.launch({
headless: true,
- executablePath: process.env.VECTOR_ZERO_CHROMIUM || chromium.executablePath(),
- args: ["--no-sandbox", "--disable-setuid-sandbox"],
+ executablePath: process.env.GAMES_CHROMIUM || process.env.VECTOR_ZERO_CHROMIUM || chromium.executablePath(),
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const errors = [];
@@ -19,7 +19,7 @@ const core = require("../core.js");
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("request", (request) => requests.push(request.url()));
- const url = pathToFileURL(path.join(__dirname, "..", "index.html")).href;
+ const url = process.env.GAMES_TEST_ORIGIN ? `${process.env.GAMES_TEST_ORIGIN}/VECTOR_ZERO/index.html` : pathToFileURL(path.join(__dirname, "..", "index.html")).href;
await page.goto(url, { waitUntil: "load" });
assert.equal(await page.title(), "VECTOR ZERO — Deterministic 6DOF Mine Shooter");
assert.equal(await page.locator("#bootLayer").isVisible(), true);
@@ -166,7 +166,7 @@ const core = require("../core.js");
assert.ok(mobile.launch.top >= mobile.gameView.top - 1, "mobile launch button must remain visible");
await page.screenshot({ path: path.join(__dirname, "vector-zero-mobile.png"), fullPage: true });
- assert.deepEqual(requests.filter((requestUrl) => !requestUrl.startsWith("file://")), []);
+ assert.deepEqual(requests.filter((requestUrl) => !(process.env.GAMES_TEST_ORIGIN ? new URL(requestUrl).origin === process.env.GAMES_TEST_ORIGIN : requestUrl.startsWith("file://"))), []);
assert.deepEqual(errors, []);
await browser.close();
process.stdout.write("ok - boot, six-axis flight, combat, projection, pause, terminal replay drain, telemetry, offline boundary, and mobile layout\n");
diff --git a/VECTOR_ZERO/tests/offline.test.js b/VECTOR_ZERO/tests/offline.test.js
index c9a7682..0fa344b 100644
--- a/VECTOR_ZERO/tests/offline.test.js
+++ b/VECTOR_ZERO/tests/offline.test.js
@@ -19,9 +19,7 @@ assert.match(app, /requestAnimationFrame\(frame\)/);
assert.match(app, /core\.tryRecordInput/);
assert.match(app, /core\.encodeReplay/);
assert.match(app, /core\.decodeReplay/);
-assert.match(app, /function clipNear/);
assert.match(app, /function buildMineFaces/);
-assert.match(app, /function drawEnemy/);
assert.match(app, /function createAudio/);
assert.match(notice, /independently implemented/i);
assert.match(notice, /No source code/i);
@@ -31,3 +29,8 @@ for (const relative of ["core.js", "app.js", "style.css", "index.html", "README.
}
process.stdout.write("ok - VECTOR ZERO is a self-contained offline 6DOF browser game\n");
+
+const renderer = fs.readFileSync(path.join(root, "renderer.js"), "utf8");
+assert.doesNotMatch(app + renderer, /getContext\s*\(\s*["']2d["']/);
+assert.match(renderer, /QsolNative\.Stage/);
+assert.match(renderer, /s\.finish\(\)/);
diff --git a/docs/NATIVE_RENDERING.md b/docs/NATIVE_RENDERING.md
new file mode 100644
index 0000000..bf02c44
--- /dev/null
+++ b/docs/NATIVE_RENDERING.md
@@ -0,0 +1,120 @@
+# Native gameplay rendering migration
+
+The previous stage added an independent translucent WebGL canvas while each game
+continued to draw its complete playable world with Canvas 2D. That architecture
+has been removed. `shared/qsol-three-stage.js` and its blending CSS are retired.
+
+Every migrated title now constructs its own renderer adapter, backed by
+`QsolNative.Stage`: one `THREE.WebGLRenderer` bound to the **existing** game canvas,
+one `THREE.Scene`, and a real camera. The simulation's scheduler calls the adapter
+after its existing fixed-step updates. The adapter reads state and uploads instance
+transforms, colors and counts; it never calls the simulation RNG or changes state.
+All six `core.js` files remain unchanged.
+
+## Per-game audit and resulting scene
+
+| Game | Simulation | Primary gameplay renderer | Camera | Native Three.js entities | Legacy Canvas gameplay renderer |
+|---|---|---|---|---|---|
+| BLACKSTAR_AGA | Existing fixed-point campaign and input receipts | Three.js/WebGL | Perspective FPS | Collision-grid rooms, doors, ceiling ribs, faceted opposition, pickups, exit console, camera-relative weapon, muzzle effects | No |
+| CARTRIDGE_ZERO | All seven existing canonical programs and CZ01 receipts | Three.js/WebGL | Orthographic with cabinet projection | Bricks, paddle, ball; lanes and ships; formations and diving craft; river banks, fuel and towers; walls, tanks and shells; aircraft, boats, subs and turrets | No |
+| PIXELWARFRONT | Existing fixed-tick RTS and command receipts | Three.js/WebGL | Orthographic with cabinet projection | Terrain, resources, HQ, relays, refinery tanks, factories, turrets, drones, rangers, crawlers, bullets, health/construction bars, selection and order indicators | No |
+| VECTOR_ZERO | Existing fixed-point 6DOF mines and VZ02 receipts | Three.js/WebGL | Perspective, oriented by simulation basis | BufferGeometry mine surfaces, LineSegments seams, faceted and wireframe enemies, cores, supplies, projectiles, exit rings and cockpit geometry | No |
+| SEEDSTORM | Existing fixed-tick shooter and input receipts | Three.js/WebGL | Orthographic | Extruded strike craft and six enemy silhouettes, bullets, pickups, focus and invulnerability rings, boss/health bars, bomb wave, impacts and industrial canyon strata | No |
+| SUBSPACE / INERTIA ZERO | Existing 60 Hz inertial combat and campaign logic | Three.js/WebGL | Following orthographic | Eight extruded hulls, asteroids, crystals, fortifications, projectiles, pickups, objectives, shields, engine trails, explosion waves and offscreen threats | No |
+
+BLACKSTAR's old `raycast`, column drawing, sprite painter and software z-buffer
+are gone. Its fixed-point map, blocking-cell rules, enemy AI and hitscan remain
+in the core. VECTOR retains surface extraction and diagnostic projection helpers;
+its Canvas near-plane clipping and painter's algorithm are gone. Three.js performs
+clipping and depth testing on the actual mine geometry.
+
+CARTRIDGE's seven `draw*` pipelines are replaced individually. GRIDBURN retains its
+lane/depth mapping, RIFT RUNNER interpolates the canonical river rows, IRON CIRCUIT
+uses DIR16 for turret direction, and SKYWATER retains both player and AI turrets.
+Each mode has its own scene inventory and palette, including monochrome mode.
+
+PIXELWARFRONT's resources, buildings, units, selection box and order paths now live
+in GPU batches. Its ground-plane projection remains identical to simulation/input
+coordinates; a cabinet projection reveals elevated surfaces. SEEDSTORM keeps its
+original craft outlines as extruded geometry with faceted cores and separate combat
+and terrain depths. SUBSPACE retains fixed-step interpolation and cloak visibility
+rules, renders its eight original silhouettes as geometry, and keeps SVG radar as
+an auxiliary tactical display.
+
+## UI boundary
+
+Menus, receipts, scoring panels and conventional text HUDs remain HTML. BLACKSTAR
+and VECTOR automaps and SUBSPACE radar are SVG UI. World-space health bars,
+selection rings, projectile trails, focus rings, objective geometry and other
+in-world indicators are WebGL objects. **There is no remaining 2D-canvas use**, not
+even texture generation, in these six titles. No CanvasTexture or image of the old
+game renderer is used. The local Three.js distribution is still bundled for offline
+classic-script loading; these games no longer load the unused Wasm overlay sampler.
+
+## Performance and lifecycle
+
+Repeated primitives are `InstancedMesh` batches keyed by semantic category, shape
+and material. Buffers double only when a population first exceeds capacity; normal
+frames update transforms/colors/counts in place. Empty populations have zero draw
+count, so removed entities cannot leave stale meshes. Shapes and materials are
+shared. VECTOR rebuilds its static mine surface and edge geometry only when the
+blueprint signature changes and disposes the replaced resources.
+
+The renderer uses the CSS canvas bounds, caps device pixel ratio at 2, and keeps
+logical simulation dimensions independent of its drawing buffer. Reduced-motion
+changes are observed live: decorative rotation, scrolling and exhaust/spark motion
+are reduced without freezing gameplay. Context loss is reported in the HUD and
+restoration resumes rendering from current state. Non-persisted pagehide disposes
+renderer, geometries, instance resources, materials and listeners; bfcache preserves
+the live instance. Explicit disposal is idempotent.
+
+## Verification
+
+- `node tests/native-renderers.static.js`: walks each entrypoint's local script
+ dependencies, rejects context acquisition/CanvasTexture/OffscreenCanvas outside
+ the vendored renderer, and checks the one-canvas native adapter boundary.
+- `node tests/native-renderers.browser.js`: real Chromium/WebGL; throws on every
+ 2D-context request, checks renderer/scene/camera object types, scene populations,
+ finite instance data, canonical-state-to-instance positions and nonblank GPU
+ pixels. Hiding all gameplay groups must remove substantial framebuffer content.
+ Tests resizing, live motion preference changes, context loss/recovery, repeated
+ scene synchronization without geometry growth/state mutation, and disposal.
+- Existing per-game core, offline and browser smoke suites cover controls, combat,
+ all seven cartridge modes, pause/restart, receipts, RTS selection/commands and
+ mobile layouts. SUBSPACE's DOM-only harness is explicitly simulation-only.
+- Browser tests accept `GAMES_CHROMIUM` for an installed Chromium executable and
+ `GAMES_TEST_ORIGIN` for an isolated HTTP test server. Without the latter they
+ exercise direct-file startup. Neither setting changes production code.
+
+Development example (Playwright is a development-only dependency):
+
+```sh
+npm --prefix BLACKSTAR_AGA ci
+npx --prefix BLACKSTAR_AGA playwright install chromium
+NODE_PATH=./BLACKSTAR_AGA/node_modules node tests/native-renderers.browser.js
+```
+
+The `Native Three.js gameplay` workflow runs the static and real-WebGL gates and
+retains screenshots as build artifacts. See the PR for the exact executed results.
+
+## Removal gate
+
+For **each of the six titles**, removing the sole Three.js canvas removes the
+rendered gameplay world: player, opposition, projectiles and world geometry.
+Only conventional interface elements remain. There is no complete playable visual
+scene underneath or above WebGL.
+
+## Audited source boundaries
+
+| Title | State read by presentation | Replaced routines | UI retained |
+|---|---|---|---|
+| BLACKSTAR_AGA | `player`, `blueprint`, doors via `isBlockingCell`, `enemies`, `pickups`, `events` | `drawWorld`, `drawEnemySprite`, `drawEntities`, `drawWeapon`, software HUD/map | DOM controls, receipts and telemetry; DOM vital/ammo HUD and SVG map |
+| CARTRIDGE_ZERO | `gameId`, `game` with each program's bricks/ball/lanes/rows/entities/tanks/targets/shots | `drawPrism`, `drawGridburn`, `drawOrbital`, `drawTalon`, `drawRiver`, `drawCircuit`, `drawSkywater` | Existing console UI, color switch, receipts; text-only in-game status |
+| PIXELWARFRONT | `blueprint.terrain`, `resources`, `buildings`, `units`, `projectiles`; selected IDs and visual effects | `drawBackground`, `drawResource`, `drawBuilding`, `drawUnit`, `drawProjectile`, selection/order/effect painters | Existing selection/production/mission UI and receipts |
+| VECTOR_ZERO | `blueprint.cells/exit`, `player` basis, `enemies`, `pickups`, `projectiles` | `faceDrawable`, `drawFace`, `drawEnemy`, `drawPickup`, `drawProjectile`, cockpit painter | DOM cockpit vitals and target notice; SVG layer map; existing controls/receipts |
+| SEEDSTORM | `player`, `enemies`, `playerBullets`, `enemyBullets`, `pickups`, `bombPulse`; hit/effect records | `drawBackground`, `drawPlayer`, `drawEnemy`, `drawEntities`, impact/explosion and world-HUD painters | Existing score/lives/bombs/sector panels and receipts |
+| SUBSPACE | `arena`, `ships`, `projectiles`, `pickups`, `particles`, `objectiveState`, interpolated positions | Canvas `render`, all world `draw*` methods, software radar | Existing HTML HUD, menu, help, score storage and audio; SVG radar |
+
+Input packing, collision, AI, scores, progression, audio scheduling, replay
+recording/draining, and SUBSPACE's save/high-score code remain simulation/UI work.
+Only presentation and its explicitly identified test boundary are replaced.
diff --git a/shared/qsol-native.css b/shared/qsol-native.css
new file mode 100644
index 0000000..5e2d9e2
--- /dev/null
+++ b/shared/qsol-native.css
@@ -0,0 +1,55 @@
+/* Text-only conventional HUD; the sole gameplay canvas is opaque WebGL. */
+.native-flash {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ display: none;
+ opacity: 0;
+ pointer-events: none;
+}
+.native-hud {
+ position: absolute;
+ left: 2%;
+ right: 2%;
+ bottom: 2%;
+ z-index: 2;
+ pointer-events: none;
+ white-space: pre-line;
+ color: #e5d5b0;
+ font: 600 clamp(10px, 1.1vw, 15px)/1.55 monospace;
+ text-shadow: 0 2px 3px #000;
+ background: rgba(5, 10, 11, 0.78);
+ padding: 0.45em 0.7em;
+ border-left: 2px solid #b58c51;
+}
+.native-hud:empty {
+ display: none;
+}
+.native-error {
+ position: absolute;
+ inset: 25% 10%;
+ z-index: 20;
+ background: #141b1d;
+ color: #e5d5b0;
+ padding: 2em;
+}
+.native-map {
+ position: absolute;
+ inset: 12% 25%;
+ width: 50%;
+ height: 65%;
+ z-index: 3;
+ pointer-events: none;
+ background: #081211ed;
+}
+.native-reticle {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ color: #dbc89b;
+ font: 22px monospace;
+ pointer-events: none;
+ z-index: 2;
+ text-shadow: 0 1px 3px #000;
+}
diff --git a/shared/qsol-native.js b/shared/qsol-native.js
new file mode 100644
index 0000000..4e9e482
--- /dev/null
+++ b/shared/qsol-native.js
@@ -0,0 +1,425 @@
+/* Native gameplay infrastructure. One existing canvas, no secondary renderer or 2D surface. */
+(function (root) {
+ "use strict";
+ const T = root.THREE;
+ class Stage {
+ constructor(
+ canvas,
+ {
+ width = canvas.width,
+ height = canvas.height,
+ perspective = false,
+ fov = 55,
+ background = "#080d10",
+ } = {},
+ ) {
+ this.canvas = canvas;
+ this.width = width;
+ this.height = height;
+ this.scene = new T.Scene();
+ this.scene.background = new T.Color(background);
+ this.camera = perspective
+ ? new T.PerspectiveCamera(fov, width / height, 0.03, 200)
+ : new T.OrthographicCamera(0, width, 0, -height, 0.1, 6000);
+ if (!perspective) this.camera.position.z = 2000;
+ try {
+ this.renderer = new T.WebGLRenderer({
+ canvas,
+ antialias: true,
+ alpha: false,
+ });
+ } catch (error) {
+ const message = document.createElement("p");
+ message.className = "native-error";
+ message.textContent =
+ "This game requires WebGL. Enable hardware acceleration and reload.";
+ canvas.parentElement.appendChild(message);
+ throw error;
+ }
+ this.renderer.outputColorSpace = T.SRGBColorSpace;
+ this.scene.add(new T.HemisphereLight(0xdce9e5, 0x25303a, 1.5));
+ const light = new T.DirectionalLight(0xffdcb2, 2.2);
+ light.position.set(-300, 500, 800);
+ this.scene.add(light);
+ this.groups = new Map();
+ this.geometries = new Map();
+ this.batches = new Map();
+ this.scratch = new T.Object3D();
+ this.color = new T.Color();
+ this.geometries.set("box", new T.BoxGeometry(1, 1, 1));
+ const slab = new T.Shape();
+ slab.moveTo(-0.46, -0.46);
+ slab.lineTo(0.46, -0.46);
+ slab.lineTo(0.46, 0.46);
+ slab.lineTo(-0.46, 0.46);
+ slab.closePath();
+ const bevel = new T.ExtrudeGeometry(slab, {
+ depth: 0.86,
+ bevelEnabled: true,
+ bevelThickness: 0.07,
+ bevelSize: 0.04,
+ bevelSegments: 1,
+ steps: 1,
+ });
+ bevel.translate(0, 0, -0.43);
+ this.geometries.set("bevel", bevel);
+ this.geometries.set("sphere", new T.IcosahedronGeometry(1, 1));
+ this.geometries.set("rock", new T.IcosahedronGeometry(1, 0));
+ this.geometries.set("diamond", new T.OctahedronGeometry(1));
+ this.geometries.set("ring", new T.TorusGeometry(1, 0.045, 4, 40));
+ this.geometries.set("cylinder", new T.CylinderGeometry(1, 1, 1, 10));
+ this.shape("dart", [
+ [0, -1],
+ [-0.9, 0.75],
+ [0, 0.35],
+ [0.9, 0.75],
+ ]);
+ this.material = new T.MeshStandardMaterial({
+ roughness: 0.7,
+ metalness: 0.25,
+ flatShading: true,
+ });
+ this.glow = new T.MeshBasicMaterial({ toneMapped: false });
+ this.wire = new T.MeshBasicMaterial({ wireframe: true });
+ this.ownedNodes = [];
+ this.flashLayer = document.createElement("div");
+ this.flashLayer.className = "native-flash";
+ canvas.parentElement.appendChild(this.flashLayer);
+ this.ownedNodes.push(this.flashLayer);
+ this.hud = document.createElement("div");
+ this.hud.className = "native-hud";
+ canvas.parentElement.appendChild(this.hud);
+ this.media = root.matchMedia("(prefers-reduced-motion: reduce)");
+ this.reducedMotion = this.media.matches;
+ this.onMotion = (event) => {
+ this.reducedMotion = event.matches;
+ };
+ this.media.addEventListener("change", this.onMotion);
+ this.lost = false;
+ this.disposed = false;
+ this.onLost = (event) => {
+ event.preventDefault();
+ this.lost = true;
+ this.hud.textContent = "WebGL context lost — waiting for recovery";
+ };
+ this.onRestored = () => {
+ this.lost = false;
+ };
+ canvas.addEventListener("webglcontextlost", this.onLost);
+ canvas.addEventListener("webglcontextrestored", this.onRestored);
+ this.onPageHide = (event) => {
+ if (!event.persisted) this.dispose();
+ };
+ root.addEventListener("pagehide", this.onPageHide);
+ // Read-only diagnostic entry point also used by the actual-WebGL regression suite.
+ canvas.nativeStage = this;
+ }
+ shape(name, points) {
+ if (this.geometries.has(name)) return;
+ const shape = new T.Shape();
+ points.forEach(([x, y], i) =>
+ i ? shape.lineTo(x, -y) : shape.moveTo(x, -y),
+ );
+ shape.closePath();
+ const geometry = new T.ExtrudeGeometry(shape, {
+ depth: 0.35,
+ bevelEnabled: true,
+ bevelThickness: 0.08,
+ bevelSize: 0.06,
+ bevelSegments: 1,
+ steps: 1,
+ });
+ geometry.translate(0, 0, -0.175);
+ this.geometries.set(name, geometry);
+ }
+ group(name) {
+ if (!this.groups.has(name)) {
+ const group = new T.Group();
+ group.name = name;
+ this.groups.set(name, group);
+ this.scene.add(group);
+ }
+ return this.groups.get(name);
+ }
+ begin(background) {
+ if (background) this.scene.background.set(background);
+ for (const batch of this.batches.values()) batch.used = 0;
+ }
+ item(
+ group,
+ shape,
+ x,
+ y,
+ z,
+ sx,
+ sy,
+ sz,
+ color,
+ rx = 0,
+ ry = 0,
+ rz = 0,
+ style = "solid",
+ ) {
+ const key = `${group}/${shape}/${style}`;
+ let batch = this.batches.get(key);
+ if (!batch) {
+ const material =
+ style === "glow"
+ ? this.glow
+ : style === "wire"
+ ? this.wire
+ : this.material;
+ const mesh = new T.InstancedMesh(
+ this.geometries.get(shape),
+ material,
+ 32,
+ );
+ mesh.name = key;
+ mesh.count = 0;
+ mesh.frustumCulled = false;
+ mesh.instanceMatrix.setUsage(T.DynamicDrawUsage);
+ this.group(group).add(mesh);
+ batch = { mesh, used: 0, capacity: 32 };
+ this.batches.set(key, batch);
+ }
+ if (batch.used === batch.capacity) {
+ const old = batch.mesh,
+ mesh = new T.InstancedMesh(
+ old.geometry,
+ old.material,
+ batch.capacity * 2,
+ );
+ mesh.name = key;
+ mesh.frustumCulled = false;
+ mesh.instanceMatrix.setUsage(T.DynamicDrawUsage);
+ mesh.instanceMatrix.array.set(old.instanceMatrix.array);
+ if (old.instanceColor) {
+ mesh.setColorAt(0, this.color);
+ mesh.instanceColor.array.set(old.instanceColor.array);
+ }
+ old.parent.add(mesh);
+ old.parent.remove(old);
+ old.dispose();
+ batch.mesh = mesh;
+ batch.capacity *= 2;
+ }
+ const object = this.scratch;
+ object.position.set(x, y, z);
+ object.scale.set(
+ Math.max(0.00001, sx),
+ Math.max(0.00001, sy),
+ Math.max(0.00001, sz),
+ );
+ object.rotation.set(rx, ry, rz);
+ object.updateMatrix();
+ batch.mesh.setMatrixAt(batch.used, object.matrix);
+ batch.mesh.setColorAt(batch.used, this.color.set(color));
+ batch.used++;
+ return batch.mesh;
+ }
+ flat(group, shape, x, y, z, sx, sy, sz, color, angle = 0, style = "solid") {
+ return this.item(
+ group,
+ shape,
+ x,
+ -y,
+ z,
+ sx,
+ sy,
+ sz,
+ color,
+ 0,
+ 0,
+ -angle,
+ style,
+ );
+ }
+ line(group, x1, y1, x2, y2, z, color, width = 1) {
+ this.flat(
+ group,
+ "box",
+ (x1 + x2) / 2,
+ (y1 + y2) / 2,
+ z,
+ Math.hypot(x2 - x1, y2 - y1),
+ width,
+ width,
+ color,
+ Math.atan2(y2 - y1, x2 - x1),
+ "glow",
+ );
+ }
+ ring(group, x, y, z, r, color) {
+ this.flat(group, "ring", x, y, z, r, r, 1, color, 0, "glow");
+ }
+ health(x, y, z, width, ratio, color) {
+ this.flat("indicators", "box", x, y, z, width, 3, 1, "#10191b");
+ const w = width * Math.max(0, Math.min(1, ratio));
+ this.flat(
+ "indicators",
+ "box",
+ x - width / 2 + w / 2,
+ y,
+ z + 1,
+ w,
+ 2,
+ 1,
+ color,
+ 0,
+ "glow",
+ );
+ }
+ effects(effects, tick) {
+ for (const e of effects) {
+ const age = tick - e.startTick,
+ life = 1 - age / e.duration;
+ if (life <= 0 || age < 0) continue;
+ const major = Boolean(e.major || e.boss),
+ eventRadius = Number.isFinite(e.radius) ? Math.max(0, e.radius) : 24,
+ scale =
+ major || e.type === "impact"
+ ? 1
+ : Math.max(0.75, eventRadius / 24),
+ r = (major ? 65 : 24 * scale) * (1 - life) + 3 * scale;
+ this.ring("effects", e.x, e.y, 25, r, "#d89459");
+ if (!this.reducedMotion)
+ for (let i = 0; i < 10; i++) {
+ const a = i * 2.399 + ((e.seed || 0) % 17),
+ d = r * (0.5 + i / 10);
+ this.flat(
+ "effects",
+ "diamond",
+ e.x + Math.cos(a) * d,
+ e.y + Math.sin(a) * d,
+ 20 + i * 2,
+ 2 * life,
+ 2 * life,
+ 4 * life,
+ i % 2 ? "#bbc8c1" : "#db7649",
+ a,
+ );
+ }
+ }
+ }
+ screenFlash(color, opacity) {
+ const amount = Math.max(0, Math.min(1, Number(opacity) || 0));
+ this.flashLayer.style.background = color || "#efe0c8";
+ this.flashLayer.style.opacity = String(amount);
+ this.flashLayer.style.display = amount > 0 ? "block" : "none";
+ }
+ shakeView(x = 0, y = 0) {
+ this.resize();
+ const sx = this.reducedMotion ? 0 : Number(x) || 0,
+ sy = this.reducedMotion ? 0 : Number(y) || 0,
+ w = this.cssWidth || this.width,
+ h = this.cssHeight || this.height,
+ scaleX = w / Math.max(1, this.width),
+ scaleY = h / Math.max(1, this.height);
+ if ((sx || sy) && this.camera.setViewOffset) {
+ this.camera.setViewOffset(
+ w,
+ h,
+ -sx * scaleX,
+ -sy * scaleY,
+ w,
+ h,
+ );
+ } else if (
+ this.camera.clearViewOffset &&
+ this.camera.view &&
+ this.camera.view.enabled
+ ) {
+ this.camera.clearViewOffset();
+ }
+ this.camera.userData.nativeShake = { x: sx, y: sy };
+ this.projectCamera();
+ }
+ text(value) {
+ if (!this.lost && this.hud.textContent !== value)
+ this.hud.textContent = value;
+ }
+ resize() {
+ if (this.disposed) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const w = Math.max(1, Math.round(rect.width || this.width)),
+ h = Math.max(1, Math.round(rect.height || this.height));
+ const dpr = Math.min(2, root.devicePixelRatio || 1);
+ if (this.cssWidth !== w || this.cssHeight !== h || this.dpr !== dpr) {
+ this.cssWidth = w;
+ this.cssHeight = h;
+ this.dpr = dpr;
+ this.renderer.setPixelRatio(dpr);
+ this.renderer.setSize(w, h, false);
+ if (this.camera.isPerspectiveCamera) {
+ this.camera.aspect = w / h;
+ this.camera.updateProjectionMatrix();
+ }
+ }
+ }
+ oblique(x, y) {
+ // Cabinet projection: z=0 retains exact simulation/input coordinates.
+ // Only raised geometry shifts, revealing thickness in the retro playfields.
+ this.skew = { x, y };
+ this.projectCamera();
+ }
+ projectCamera() {
+ this.camera.updateProjectionMatrix();
+ if (this.skew && this.camera.isOrthographicCamera) {
+ const x = (2 * this.skew.x) / (this.camera.right - this.camera.left);
+ const y = (2 * this.skew.y) / (this.camera.top - this.camera.bottom);
+ const m = this.camera.projectionMatrix.elements;
+ m[8] += x;
+ m[9] += y;
+ m[12] += x * this.camera.position.z;
+ m[13] += y * this.camera.position.z;
+ this.camera.projectionMatrixInverse
+ .copy(this.camera.projectionMatrix)
+ .invert();
+ }
+ }
+ view(width, height, x = 0, y = 0) {
+ this.width = width;
+ this.height = height;
+ Object.assign(this.camera, {
+ left: x,
+ right: x + width,
+ top: -y,
+ bottom: -y - height,
+ });
+ this.projectCamera();
+ }
+ finish() {
+ if (this.disposed || this.lost) return;
+ for (const b of this.batches.values()) {
+ b.mesh.count = b.used;
+ b.mesh.instanceMatrix.needsUpdate = true;
+ if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
+ }
+ this.resize();
+ this.renderer.render(this.scene, this.camera);
+ }
+ dispose() {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.media.removeEventListener("change", this.onMotion);
+ root.removeEventListener("pagehide", this.onPageHide);
+ this.canvas.removeEventListener("webglcontextlost", this.onLost);
+ this.canvas.removeEventListener("webglcontextrestored", this.onRestored);
+ for (const b of this.batches.values()) b.mesh.dispose();
+ for (const g of this.geometries.values()) g.dispose();
+ this.material.dispose();
+ this.glow.dispose();
+ this.wire.dispose();
+ this.scene.traverse((o) => {
+ if (o.userData.ownedGeometry) {
+ o.geometry.dispose();
+ o.material.dispose();
+ }
+ });
+ this.renderer.dispose();
+ this.hud.remove();
+ this.ownedNodes.forEach((node) => node.remove());
+ }
+ }
+ root.QsolNative = { Stage };
+})(window);
diff --git a/shared/qsol-three-stage.js b/shared/qsol-three-stage.js
deleted file mode 100644
index 9d21a94..0000000
--- a/shared/qsol-three-stage.js
+++ /dev/null
@@ -1,376 +0,0 @@
-(function installQsolThree(root) {
- "use strict";
-
- const PRESETS = Object.freeze({
- blackstar: Object.freeze({ ink: 0xd69a58, signal: 0x84b8aa, danger: 0xb7503d, stars: 150, speed: 0.75, opacity: 0.42 }),
- cartridge: Object.freeze({ ink: 0xd8b66e, signal: 0x668f88, danger: 0xb95f43, stars: 90, speed: 0.28, opacity: 0.34 }),
- warfront: Object.freeze({ ink: 0xd0b36d, signal: 0x6f9b8c, danger: 0xc45d40, stars: 75, speed: 0.18, opacity: 0.38 }),
- seedstorm: Object.freeze({ ink: 0xe1b868, signal: 0x6c9890, danger: 0xd15d42, stars: 210, speed: 1.35, opacity: 0.5 }),
- inertia: Object.freeze({ ink: 0xcab77e, signal: 0x72aaa0, danger: 0xc96348, stars: 260, speed: 0.62, opacity: 0.4 }),
- vector: Object.freeze({ ink: 0xcdb777, signal: 0x6b9d93, danger: 0xd06045, stars: 230, speed: 1.05, opacity: 0.48 }),
- ternary: Object.freeze({ ink: 0xd5b56c, signal: 0x6ca294, danger: 0xcb5c43, stars: 360, speed: 0.7, opacity: 0.56 }),
- });
-
- const NOOP = Object.freeze({
- supported: false,
- render() {},
- pulse() {},
- dispose() {},
- });
-
- function finite(value, fallback) {
- return Number.isFinite(value) ? value : fallback;
- }
-
- function clamp(value, low, high) {
- return value < low ? low : value > high ? high : value;
- }
-
- function hashText(text) {
- let hash = 2166136261;
- for (let index = 0; index < text.length; index += 1) {
- hash ^= text.charCodeAt(index);
- hash = Math.imul(hash, 16777619);
- }
- return hash >>> 0;
- }
-
- function makeRng(seedText) {
- let state = hashText(seedText) || 0x9e3779b9;
- return function random() {
- state ^= state << 13;
- state ^= state >>> 17;
- state ^= state << 5;
- return (state >>> 0) / 4294967296;
- };
- }
-
- function createStars(THREE, config, random) {
- const positions = new Float32Array(config.stars * 3);
- const origins = new Float32Array(config.stars * 3);
- for (let index = 0; index < config.stars; index += 1) {
- const offset = index * 3;
- const radius = 3 + random() * 13;
- const angle = random() * Math.PI * 2;
- positions[offset] = Math.cos(angle) * radius * (0.5 + random());
- positions[offset + 1] = Math.sin(angle) * radius * (0.35 + random() * 0.7);
- positions[offset + 2] = -2 - random() * 22;
- origins.set(positions.subarray(offset, offset + 3), offset);
- }
- const geometry = new THREE.BufferGeometry();
- geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
- const material = new THREE.PointsMaterial({
- color: config.signal,
- size: 0.055,
- sizeAttenuation: true,
- transparent: true,
- opacity: 0.56,
- depthWrite: false,
- blending: THREE.AdditiveBlending,
- });
- const points = new THREE.Points(geometry, material);
- points.frustumCulled = false;
- return { points, positions, origins, geometry, material };
- }
-
- function decodeBase64(text) {
- const binary = root.atob(text);
- const bytes = new Uint8Array(binary.length);
- for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
- return bytes;
- }
-
- async function applyGalaxySample(stars, config, preset, layer) {
- if (!root.GALAXY_WASM_BASE64 || !root.WebAssembly || !root.atob) return;
- try {
- const result = await root.WebAssembly.instantiate(decodeBase64(root.GALAXY_WASM_BASE64), {});
- const wasm = result.instance.exports;
- if (!wasm.generate || wasm.abi_version() < 2 || wasm.generate(65536, config.stars, hashText(`QSOL-${preset}`)) !== config.stars) return;
- const sample = new Float32Array(wasm.memory.buffer, wasm.buffer_ptr(), wasm.buffer_len());
- for (let index = 0; index < config.stars; index += 1) {
- const target = index * 3;
- const source = index * 8;
- const radius = 3 + sample[source] * 13;
- const angle = sample[source + 1] * Math.PI * 2;
- stars.positions[target] = Math.cos(angle) * radius * (0.5 + sample[source + 2]);
- stars.positions[target + 1] = Math.sin(angle) * radius * (0.35 + sample[source + 3] * 0.7);
- stars.positions[target + 2] = -2 - sample[source + 4] * 22;
- }
- stars.origins.set(stars.positions);
- stars.geometry.attributes.position.needsUpdate = true;
- layer.dataset.sampler = "galaxy-rust-wasm";
- } catch (_error) {
- layer.dataset.sampler = "javascript-fallback";
- }
- }
-
- function wireMaterial(THREE, color, opacity) {
- return new THREE.MeshBasicMaterial({
- color,
- wireframe: true,
- transparent: true,
- opacity,
- depthWrite: false,
- blending: THREE.AdditiveBlending,
- });
- }
-
- function lineMaterial(THREE, color, opacity) {
- return new THREE.LineBasicMaterial({
- color,
- transparent: true,
- opacity,
- depthWrite: false,
- blending: THREE.AdditiveBlending,
- });
- }
-
- function addRing(THREE, group, material, radius, z, tilt) {
- const mesh = new THREE.Mesh(new THREE.TorusGeometry(radius, 0.018, 4, 64), material.clone());
- mesh.position.z = z;
- mesh.rotation.x = tilt || 0;
- group.add(mesh);
- return mesh;
- }
-
- function addWireMesh(THREE, group, geometry, material, x, y, z, scale) {
- const mesh = new THREE.Mesh(geometry, material.clone());
- mesh.position.set(x, y, z);
- mesh.scale.setScalar(scale || 1);
- group.add(mesh);
- return mesh;
- }
-
- function buildMotif(THREE, preset, config) {
- const group = new THREE.Group();
- const primary = wireMaterial(THREE, config.ink, 0.26);
- const signal = wireMaterial(THREE, config.signal, 0.22);
- const meshes = [];
-
- if (preset === "blackstar") {
- for (let index = 0; index < 6; index += 1) {
- const mesh = addWireMesh(THREE, group, new THREE.BoxGeometry(7.8, 4.5, 2.6), index & 1 ? signal : primary, 0, 0.1, -3.5 - index * 3.6, 1);
- meshes.push(mesh);
- }
- } else if (preset === "cartridge") {
- const shapes = [
- new THREE.TetrahedronGeometry(0.8), new THREE.OctahedronGeometry(0.72), new THREE.IcosahedronGeometry(0.67),
- new THREE.BoxGeometry(1.1, 1.1, 1.1), new THREE.ConeGeometry(0.72, 1.3, 5), new THREE.TorusGeometry(0.62, 0.16, 5, 16),
- new THREE.CylinderGeometry(0.55, 0.75, 1.1, 6),
- ];
- shapes.forEach((shape, index) => {
- const angle = index / shapes.length * Math.PI * 2;
- meshes.push(addWireMesh(THREE, group, shape, index & 1 ? signal : primary, Math.cos(angle) * 4.1, Math.sin(angle) * 2.2, -2 - (index % 3), 1));
- });
- meshes.push(addRing(THREE, group, primary, 3.1, -4.5, 0.55));
- } else if (preset === "warfront") {
- const grid = new THREE.GridHelper(18, 18, config.ink, config.signal);
- grid.rotation.x = Math.PI / 2;
- grid.position.set(0, -0.8, -7);
- grid.material.transparent = true;
- grid.material.opacity = 0.17;
- grid.material.depthWrite = false;
- group.add(grid);
- for (let index = 0; index < 8; index += 1) {
- const x = (index % 4 - 1.5) * 2.2;
- const y = (Math.floor(index / 4) - 0.5) * 2.4;
- meshes.push(addWireMesh(THREE, group, new THREE.CylinderGeometry(0.34, 0.56, 1.2, 6), index % 3 ? signal : primary, x, y, -4.5, 1));
- }
- } else if (preset === "seedstorm") {
- for (let index = 0; index < 9; index += 1) meshes.push(addRing(THREE, group, index % 3 ? signal : primary, 2.2 + index * 0.65, -5 - index * 2.2, index * 0.11));
- meshes.push(addWireMesh(THREE, group, new THREE.ConeGeometry(0.75, 2, 5), primary, 0, -2.3, -2.8, 1));
- } else if (preset === "inertia") {
- meshes.push(addWireMesh(THREE, group, new THREE.TorusKnotGeometry(2.8, 0.12, 96, 8, 2, 3), signal, 3.8, -1.6, -7, 1));
- meshes.push(addWireMesh(THREE, group, new THREE.IcosahedronGeometry(1.4, 1), primary, -4.2, 2.1, -5, 1));
- meshes.push(addRing(THREE, group, primary, 5.8, -10, 0.9));
- } else if (preset === "vector") {
- for (let index = 0; index < 11; index += 1) meshes.push(addRing(THREE, group, index % 2 ? signal : primary, 2.5 + index * 0.42, -3 - index * 2.15, index * 0.16));
- meshes.push(addWireMesh(THREE, group, new THREE.IcosahedronGeometry(1.1, 1), primary, 0, 0, -5, 1));
- } else {
- meshes.push(addRing(THREE, group, primary, 5.2, -9, 0.95));
- meshes.push(addRing(THREE, group, signal, 3.6, -6, -0.6));
- meshes.push(addWireMesh(THREE, group, new THREE.OctahedronGeometry(1.35, 1), signal, -3.7, 1.8, -5.5, 1));
- meshes.push(addWireMesh(THREE, group, new THREE.TorusKnotGeometry(1.4, 0.12, 64, 6, 2, 3), primary, 4.2, -1.8, -7, 1));
- }
-
- primary.dispose();
- signal.dispose();
- return { group, meshes };
- }
-
- function createBurst(THREE, config, random) {
- const count = 48;
- const positions = new Float32Array(count * 3);
- const vectors = new Float32Array(count * 3);
- for (let index = 0; index < count; index += 1) {
- const offset = index * 3;
- const angle = random() * Math.PI * 2;
- const elevation = (random() - 0.5) * 1.5;
- const speed = 0.5 + random() * 1.5;
- vectors[offset] = Math.cos(angle) * speed;
- vectors[offset + 1] = Math.sin(angle) * speed;
- vectors[offset + 2] = elevation * speed;
- }
- const geometry = new THREE.BufferGeometry();
- geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
- const material = new THREE.PointsMaterial({
- color: config.danger,
- size: 0.075,
- transparent: true,
- opacity: 0,
- depthWrite: false,
- blending: THREE.AdditiveBlending,
- });
- const points = new THREE.Points(geometry, material);
- points.position.z = -1;
- return { points, positions, vectors, geometry, material };
- }
-
- function create(options) {
- const THREE = root.THREE;
- const host = options && options.host;
- const source = options && options.source;
- const preset = options && PRESETS[options.preset] ? options.preset : "ternary";
- if (!THREE || !host || !source || !root.document) return NOOP;
- // Headless automation is normally backed by a software renderer. Keep the
- // deterministic Canvas simulation authoritative instead of spending its
- // frame budget compiling a decorative WebGL scene.
- if (root.navigator && root.navigator.webdriver) return NOOP;
-
- const layer = root.document.createElement("canvas");
- layer.className = "qsol-three-layer";
- layer.setAttribute("aria-hidden", "true");
- layer.tabIndex = -1;
- const attributes = { alpha: true, antialias: false, depth: true, stencil: false, premultipliedAlpha: true };
- const context = layer.getContext("webgl2", attributes) || layer.getContext("webgl", attributes);
- if (!context) return NOOP;
- // SwiftShader/llvmpipe can make a decorative layer starve the authoritative
- // fixed-step Canvas simulation. Prefer the existing 2D game on software GL.
- const debugInfo = context.getExtension("WEBGL_debug_renderer_info");
- const rendererName = debugInfo ? String(context.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || "") : "";
- if (/swiftshader|llvmpipe|software raster/i.test(rendererName)) return NOOP;
-
- let renderer;
- try {
- renderer = new THREE.WebGLRenderer({ canvas: layer, context, alpha: true, antialias: false, powerPreference: "high-performance" });
- } catch (_error) {
- return NOOP;
- }
-
- const config = PRESETS[preset];
- const random = makeRng(`QSOL-THREE-${preset}`);
- host.classList.add("qsol-three-host");
- layer.style.setProperty("--qsol-three-opacity", String(config.opacity));
- source.insertAdjacentElement("afterend", layer);
-
- renderer.setClearColor(0x000000, 0);
- renderer.setPixelRatio(Math.min(1, root.devicePixelRatio || 1));
- renderer.outputColorSpace = THREE.SRGBColorSpace;
- const scene = new THREE.Scene();
- const camera = new THREE.PerspectiveCamera(56, 1, 0.1, 80);
- camera.position.z = 8;
- const stars = createStars(THREE, config, random);
- scene.add(stars.points);
- applyGalaxySample(stars, config, preset, layer);
- const motif = buildMotif(THREE, preset, config);
- scene.add(motif.group);
- const burst = createBurst(THREE, config, random);
- scene.add(burst.points);
-
- let width = 0;
- let height = 0;
- let pulse = 0;
- let burstAge = 2;
- let disposed = false;
- let lastTime = root.performance && root.performance.now ? root.performance.now() : 0;
-
- function resize() {
- const bounds = host.getBoundingClientRect();
- const nextWidth = Math.max(1, Math.round(bounds.width));
- const nextHeight = Math.max(1, Math.round(bounds.height));
- if (nextWidth === width && nextHeight === height) return;
- width = nextWidth;
- height = nextHeight;
- camera.aspect = width / height;
- camera.updateProjectionMatrix();
- renderer.setSize(width, height, false);
- }
-
- function render(telemetry) {
- if (disposed) return;
- resize();
- const now = root.performance && root.performance.now ? root.performance.now() : lastTime + 16.67;
- const delta = clamp((now - lastTime) / 1000, 0, 0.08);
- lastTime = now;
- const data = telemetry || {};
- const tick = finite(data.tick, 0);
- const speed = clamp(Math.abs(finite(data.speed, 0)), 0, 4);
- const danger = clamp(finite(data.danger, 0), 0, 1);
- const activity = clamp(finite(data.activity, 0), 0, 1);
- const heading = finite(data.heading, 0);
- const travel = delta * config.speed * (1 + speed * 1.7 + activity * 0.8);
- const positions = stars.positions;
- for (let index = 0; index < positions.length; index += 3) {
- positions[index + 2] += travel;
- if (positions[index + 2] > 3) positions[index + 2] = -24;
- }
- stars.geometry.attributes.position.needsUpdate = true;
- stars.material.opacity = 0.38 + activity * 0.26 + danger * 0.18;
- stars.material.size = 0.045 + speed * 0.018;
-
- const visualTime = now * 0.001;
- motif.group.rotation.z = heading * 0.08 + Math.sin(visualTime * 0.19) * 0.035;
- motif.group.rotation.x = Math.sin(visualTime * 0.13 + tick * 0.0007) * 0.045;
- motif.meshes.forEach((mesh, index) => {
- mesh.rotation.x += delta * (0.035 + (index % 3) * 0.012);
- mesh.rotation.y += delta * (0.045 + (index % 4) * 0.013);
- if (mesh.material && "opacity" in mesh.material) mesh.material.opacity = 0.13 + activity * 0.12 + danger * (index % 2 ? 0.08 : 0.16);
- });
-
- pulse = Math.max(0, pulse - delta * 2.4);
- burstAge += delta;
- if (burstAge < 1) {
- for (let index = 0; index < burst.positions.length; index += 3) {
- const distance = burstAge * 2.7;
- burst.positions[index] = burst.vectors[index] * distance;
- burst.positions[index + 1] = burst.vectors[index + 1] * distance;
- burst.positions[index + 2] = burst.vectors[index + 2] * distance;
- }
- burst.geometry.attributes.position.needsUpdate = true;
- burst.material.opacity = (1 - burstAge) * 0.9;
- } else burst.material.opacity = 0;
-
- camera.position.x = Math.sin(visualTime * 0.31) * 0.08 + Math.sin(tick * 0.17) * pulse * 0.06;
- camera.position.y = Math.cos(visualTime * 0.27) * 0.06 + Math.cos(tick * 0.13) * pulse * 0.05;
- camera.position.z = 8 - pulse * 0.2;
- layer.style.setProperty("--qsol-three-opacity", String(config.opacity * (0.72 + activity * 0.23 + danger * 0.16)));
- renderer.render(scene, camera);
- }
-
- function trigger(kind, strength) {
- const value = clamp(finite(strength, 0.65), 0, 1);
- pulse = Math.max(pulse, value);
- if (kind === "impact" || kind === "blast" || kind === "jump") burstAge = 0;
- }
-
- function dispose() {
- if (disposed) return;
- disposed = true;
- stars.geometry.dispose();
- stars.material.dispose();
- burst.geometry.dispose();
- burst.material.dispose();
- motif.group.traverse((object) => {
- if (object.geometry) object.geometry.dispose();
- if (object.material && object.material.dispose) object.material.dispose();
- });
- renderer.dispose();
- layer.remove();
- host.classList.remove("qsol-three-host");
- }
-
- return Object.freeze({ supported: true, render, pulse: trigger, dispose });
- }
-
- root.QsolThree = Object.freeze({ version: "1.0.0", presets: Object.keys(PRESETS), create });
-})(typeof window !== "undefined" ? window : globalThis);
diff --git a/shared/qsol-three.css b/shared/qsol-three.css
deleted file mode 100644
index 9479106..0000000
--- a/shared/qsol-three.css
+++ /dev/null
@@ -1,24 +0,0 @@
-.qsol-three-host {
- isolation: isolate;
- overflow: hidden;
-}
-
-.qsol-three-layer {
- position: absolute !important;
- inset: 0;
- z-index: 1;
- display: block;
- width: 100% !important;
- height: 100% !important;
- max-width: none !important;
- pointer-events: none;
- opacity: var(--qsol-three-opacity, 0.5);
- mix-blend-mode: screen;
- filter: saturate(0.78) contrast(1.08);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .qsol-three-layer {
- --qsol-three-opacity: 0.2;
- }
-}
diff --git a/tests/native-renderers.browser.js b/tests/native-renderers.browser.js
new file mode 100644
index 0000000..7b7fff7
--- /dev/null
+++ b/tests/native-renderers.browser.js
@@ -0,0 +1,487 @@
+"use strict";
+const assert = require("node:assert/strict");
+const path = require("node:path");
+const fs = require("node:fs");
+const { chromium } = require("playwright");
+const { pathToFileURL } = require("node:url");
+const root = path.resolve(__dirname, "..");
+const titles = [
+ ["BLACKSTAR_AGA", "BlackstarRenderer", "terrain", "enemies"],
+ ["CARTRIDGE_ZERO", "CartridgeRenderer", "bricks", "ball"],
+ ["PIXELWARFRONT", "WarfrontRenderer", "terrain", "units"],
+ ["VECTOR_ZERO", "VectorRenderer", "terrain", "enemies"],
+ ["SEEDSTORM", "SeedstormRenderer", "terrain", "enemies"],
+ ["SUBSPACE", "InertiaRenderer", "obstacles", "enemies"],
+];
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.GAMES_CHROMIUM || chromium.executablePath(),
+ args: ["--no-sandbox", "--enable-unsafe-swiftshader"],
+ });
+ try {
+ for (const [game, rendererName, worldGroup, actorGroup] of titles) {
+ const page = await browser.newPage({
+ viewport: { width: 1280, height: 900 },
+ }),
+ errors = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ page.on("console", (e) => {
+ if (e.type() === "error") errors.push(e.text());
+ });
+ await page.addInitScript(
+ ({ rendererName }) => {
+ const getContext = HTMLCanvasElement.prototype.getContext;
+ HTMLCanvasElement.prototype.getContext = function (type, ...args) {
+ if (type === "2d")
+ throw new Error("Canvas 2D is forbidden in native gameplay");
+ return getContext.call(this, type, ...args);
+ };
+ // Observe the real adapter boundary without changing or advancing the simulation.
+ Object.defineProperty(window, rendererName, {
+ configurable: true,
+ set(Renderer) {
+ Object.defineProperty(window, rendererName, {
+ value: class extends Renderer {
+ render(state, ...args) {
+ window.__renderState = state;
+ window.__renderArgs = args;
+ window.__presentation = this;
+ return super.render(state, ...args);
+ }
+ },
+ writable: true,
+ configurable: true,
+ });
+ },
+ });
+ },
+ { rendererName },
+ );
+ const url = process.env.GAMES_TEST_ORIGIN
+ ? `${process.env.GAMES_TEST_ORIGIN}/${game}/index.html`
+ : pathToFileURL(path.join(root, game, "index.html")).href;
+ await page.goto(url);
+ if (game === "SUBSPACE") await page.click("#launch-button");
+ else await page.click("#startButton");
+ await page.waitForFunction(
+ () =>
+ window.__renderState &&
+ (window.__renderState.tick > 15 ||
+ window.__renderState.simulationTime > 0.4),
+ );
+ if (game === "SEEDSTORM")
+ await page.waitForFunction(
+ () => window.__renderState.enemies.length > 0,
+ );
+ const report = await page.evaluate(
+ ({ game, worldGroup, actorGroup }) => {
+ const s = document.querySelector("canvas").nativeStage,
+ state = window.__renderState;
+ const count = (name) => {
+ let n = 0;
+ const group = s.scene.getObjectByName(name);
+ if (group)
+ group.traverse((o) => {
+ if (o.isMesh || o.isLineSegments)
+ n += o.isInstancedMesh ? o.count : 1;
+ });
+ return n;
+ };
+ const capture = () => {
+ s.renderer.render(s.scene, s.camera);
+ const gl = s.renderer.getContext(),
+ data = new Uint8Array(
+ gl.drawingBufferWidth * gl.drawingBufferHeight * 4,
+ );
+ gl.readPixels(
+ 0,
+ 0,
+ gl.drawingBufferWidth,
+ gl.drawingBufferHeight,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ data,
+ );
+ return data;
+ };
+ const pixels = capture(),
+ colors = new Set();
+ for (let i = 0; i < pixels.length; i += 4)
+ colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`);
+ // Remove actual gameplay meshes, leaving atmosphere intact: the image must change.
+ const keep = new Set(["stars", "grid", "menu-hull"]);
+ const hidden = [];
+ for (const [name, group] of s.groups)
+ if (!keep.has(name)) {
+ hidden.push(group);
+ group.visible = false;
+ }
+ const absent = capture();
+ let changed = 0;
+ for (let i = 0; i < pixels.length; i += 4)
+ if (
+ pixels[i] !== absent[i] ||
+ pixels[i + 1] !== absent[i + 1] ||
+ pixels[i + 2] !== absent[i + 2]
+ )
+ changed++;
+ hidden.forEach((g) => (g.visible = true));
+ s.renderer.render(s.scene, s.camera);
+ const instances = [...s.batches.values()];
+ const finite = instances.every((b) =>
+ Array.from(b.mesh.instanceMatrix.array.slice(0, b.used * 16)).every(
+ Number.isFinite,
+ ),
+ );
+ return {
+ renderer: s.renderer instanceof THREE.WebGLRenderer,
+ scene: s.scene instanceof THREE.Scene,
+ camera: s.camera.isCamera,
+ canvases: document.querySelectorAll("canvas").length,
+ world: count(worldGroup),
+ actors: count(actorGroup),
+ player: count(game === "PIXELWARFRONT" ? "units" : "player"),
+ colors: colors.size,
+ changed,
+ finite,
+ geometries: s.renderer.info.memory.geometries,
+ triangles: s.renderer.info.render.triangles,
+ };
+ },
+ { game, worldGroup, actorGroup },
+ );
+ console.log(`FRAME ${game}: ${JSON.stringify(report)}`);
+ assert.ok(
+ report.renderer && report.scene && report.camera,
+ `${game}: real Three.js runtime`,
+ );
+ assert.equal(report.canvases, 1, `${game}: one sole gameplay canvas`);
+ assert.ok(
+ report.world > 0 && report.actors > 0 && report.player > 0,
+ `${game}: scene population ${JSON.stringify(report)}`,
+ );
+ assert.ok(
+ report.colors > 20 &&
+ report.changed > 100 &&
+ report.triangles > 0 &&
+ report.finite,
+ `${game}: nonblank gameplay framebuffer ${JSON.stringify(report)}`,
+ );
+ // Validate actual state coordinates against uploaded instance transforms.
+ const aligned = await page.evaluate((game) => {
+ const s = __presentation.stage,
+ state = __renderState,
+ m = new THREE.Matrix4();
+ let group, point;
+ if (game === "BLACKSTAR_AGA") {
+ group = "enemies";
+ point = [
+ state.enemies[0].x / BlackstarCore.FP,
+ state.enemies[0].y / BlackstarCore.FP,
+ ];
+ } else if (game === "VECTOR_ZERO") {
+ group = "enemies";
+ point = [
+ state.enemies[0].x / VectorZeroCore.FP,
+ state.enemies[0].y / VectorZeroCore.FP,
+ state.enemies[0].z / VectorZeroCore.FP,
+ ];
+ } else if (game === "CARTRIDGE_ZERO") {
+ group = "ball";
+ point = [
+ state.game.ball.x / CartridgeZeroCore.FP,
+ -state.game.ball.y / CartridgeZeroCore.FP,
+ ];
+ } else if (game === "PIXELWARFRONT") {
+ group = "units";
+ point = [
+ state.units[0].x / PixelWarfrontCore.SCALE,
+ -state.units[0].y / PixelWarfrontCore.SCALE,
+ ];
+ } else if (game === "SEEDSTORM") {
+ group = "player";
+ point = [
+ state.player.x / SeedStormCore.SCALE,
+ -state.player.y / SeedStormCore.SCALE,
+ ];
+ } else {
+ group = "player";
+ point = null;
+ }
+ if (!point) return true;
+ for (const [key, b] of s.batches)
+ if (key.startsWith(group + "/"))
+ for (let i = 0; i < b.used; i++) {
+ b.mesh.getMatrixAt(i, m);
+ const e = m.elements;
+ if (game === "BLACKSTAR_AGA") {
+ if (
+ Math.abs(e[12] - point[0]) < 0.001 &&
+ Math.abs(e[14] - point[1]) < 0.001
+ )
+ return true;
+ } else if (
+ Math.abs(e[12] - point[0]) < 0.001 &&
+ Math.abs(e[13] - point[1]) < 0.001 &&
+ (!point[2] || Math.abs(e[14] - point[2]) < 0.001)
+ )
+ return true;
+ }
+ return false;
+ }, game);
+ assert.ok(aligned, `${game}: simulation-to-instance coordinates`);
+ if (
+ game === "BLACKSTAR_AGA" ||
+ game === "VECTOR_ZERO" ||
+ game === "PIXELWARFRONT" ||
+ game === "SUBSPACE"
+ ) {
+ const feedback = await page.evaluate((game) => {
+ const p = __presentation,
+ s = p.stage,
+ state = __renderState,
+ originalArgs = __renderArgs.slice();
+ let opacity = 0,
+ shake = { x: 0, y: 0 },
+ vectorContacts = null;
+ if (game === "BLACKSTAR_AGA") {
+ p.render(state, {
+ ...originalArgs[0],
+ flash: 8,
+ hurt: 0,
+ shakeX: 3,
+ shakeY: -2,
+ });
+ } else if (game === "VECTOR_ZERO") {
+ p.render(state, originalArgs[0], {
+ ...originalArgs[1],
+ flash: 8,
+ hurt: 0,
+ shakeX: 3,
+ shakeY: -2,
+ });
+ } else if (game === "PIXELWARFRONT") {
+ p.render(state, {
+ ...originalArgs[0],
+ screenFlash: 12,
+ shakeX: 3,
+ shakeY: -2,
+ });
+ } else {
+ const oldShake = state.camera.shake,
+ oldTime = state.attractTime;
+ state.camera.shake = 12;
+ state.attractTime = 0.137;
+ p.render(state, ...originalArgs);
+ state.camera.shake = oldShake;
+ state.attractTime = oldTime;
+ }
+ opacity = Number.parseFloat(s.flashLayer.style.opacity || "0");
+ shake = { ...(s.camera.userData.nativeShake || { x: 0, y: 0 }) };
+ if (game === "VECTOR_ZERO") {
+ const clone = structuredClone(state),
+ pickup = clone.pickups.find((item) => item.kind !== "core"),
+ enemy = clone.enemies[0];
+ if (pickup && enemy) {
+ pickup.y = clone.player.y;
+ enemy.y = clone.player.y;
+ p.render(clone, originalArgs[0], {
+ ...originalArgs[1],
+ automap: true,
+ flash: 0,
+ shakeX: 0,
+ shakeY: 0,
+ });
+ const map = p.map,
+ supply = map.querySelector('[data-contact^="pickup-"]:not([data-contact="pickup-core"])'),
+ hostile = map.querySelector('[data-contact="enemy"]');
+ vectorContacts = {
+ supply: supply && supply.getAttribute("fill"),
+ hostile: hostile && hostile.getAttribute("fill"),
+ };
+ }
+ }
+ p.render(state, ...originalArgs);
+ return { opacity, shake, vectorContacts };
+ }, game);
+ assert.ok(
+ Math.abs(feedback.shake.x) > 0.01 ||
+ Math.abs(feedback.shake.y) > 0.01,
+ `${game}: combat feedback must displace the native view`,
+ );
+ if (game !== "SUBSPACE")
+ assert.ok(
+ feedback.opacity > 0,
+ `${game}: combat feedback must flash the native view`,
+ );
+ if (game === "VECTOR_ZERO") {
+ assert.ok(feedback.vectorContacts?.supply);
+ assert.equal(feedback.vectorContacts.hostile, "#be7153");
+ assert.notEqual(
+ feedback.vectorContacts.supply,
+ feedback.vectorContacts.hostile,
+ "VECTOR ZERO supplies must not look hostile on the automap",
+ );
+ }
+ }
+ if (game === "SEEDSTORM") {
+ const effectRadii = await page.evaluate(() => {
+ const s = __presentation.stage,
+ matrix = new THREE.Matrix4(),
+ scale = new THREE.Vector3();
+ s.begin();
+ s.effects(
+ [
+ {
+ type: "explosion",
+ x: 0,
+ y: 0,
+ radius: 4,
+ startTick: 0,
+ duration: 10,
+ seed: 1,
+ },
+ {
+ type: "explosion",
+ x: 0,
+ y: 0,
+ radius: 60,
+ startTick: 0,
+ duration: 10,
+ seed: 2,
+ },
+ {
+ type: "explosion",
+ x: 0,
+ y: 0,
+ radius: 4,
+ startTick: 0,
+ duration: 10,
+ seed: 3,
+ boss: true,
+ },
+ ],
+ 5,
+ );
+ const batch = s.batches.get("effects/ring/glow"),
+ radii = [];
+ for (let i = 0; i < 3; i++) {
+ batch.mesh.getMatrixAt(i, matrix);
+ radii.push(scale.setFromMatrixScale(matrix).x);
+ }
+ return radii;
+ });
+ assert.ok(
+ effectRadii[1] > effectRadii[0] * 2,
+ `${game}: ordinary explosion radius must scale with entity radius`,
+ );
+ assert.ok(
+ effectRadii[2] > effectRadii[0],
+ `${game}: boss marker must select the major shared effect radius`,
+ );
+ }
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.waitForFunction(() => __presentation.stage.reducedMotion);
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ await page.waitForFunction(() => !__presentation.stage.reducedMotion);
+ await page.setViewportSize({ width: 820, height: 720 });
+ await page.waitForFunction(() => {
+ const s = __presentation.stage,
+ r = s.canvas.getBoundingClientRect();
+ return (
+ Math.abs(s.renderer.domElement.width - Math.round(r.width) * s.dpr) <=
+ 2
+ );
+ });
+ const perspectiveProjection = await page.evaluate(() => {
+ const s = __presentation.stage,
+ r = s.canvas.getBoundingClientRect();
+ return s.camera.isPerspectiveCamera
+ ? {
+ actual: s.camera.aspect,
+ expected:
+ Math.max(1, Math.round(r.width)) /
+ Math.max(1, Math.round(r.height)),
+ }
+ : null;
+ });
+ if (perspectiveProjection)
+ assert.ok(
+ Math.abs(
+ perspectiveProjection.actual - perspectiveProjection.expected,
+ ) < 1e-6,
+ `${game}: perspective projection follows resized canvas aspect`,
+ );
+ const tickBefore = await page.evaluate(
+ () => __renderState.tick || __renderState.simulationTime,
+ );
+ await page.keyboard.down(game === "SEEDSTORM" ? "KeyZ" : "Space");
+ await page.waitForTimeout(160);
+ await page.keyboard.up(game === "SEEDSTORM" ? "KeyZ" : "Space");
+ assert.ok(
+ await page.evaluate(
+ (before) =>
+ (__renderState.tick || __renderState.simulationTime) > before,
+ tickBefore,
+ ),
+ `${game}: simulation continues after resize`,
+ );
+ // Re-render a frozen state repeatedly: no GPU geometry growth, no state mutation.
+ const stable = await page.evaluate(() => {
+ const p = __presentation,
+ s = p.stage,
+ was = JSON.stringify(__renderState, (k, v) =>
+ k === "presentation" || k === "input" || k === "audio"
+ ? undefined
+ : v,
+ );
+ const n = s.renderer.info.memory.geometries;
+ for (let i = 0; i < 120; i++) p.render(__renderState, ...__renderArgs);
+ return {
+ n,
+ after: s.renderer.info.memory.geometries,
+ unchanged:
+ was ===
+ JSON.stringify(__renderState, (k, v) =>
+ k === "presentation" || k === "input" || k === "audio"
+ ? undefined
+ : v,
+ ),
+ };
+ });
+ assert.equal(stable.n, stable.after);
+ assert.ok(stable.unchanged);
+ fs.mkdirSync(path.join(root, "test-results"), { recursive: true });
+ await page.screenshot({
+ path: path.join(root, "test-results", `${game}.png`),
+ fullPage: true,
+ });
+ // Context loss and restoration must retain the only gameplay renderer.
+ await page.evaluate(() =>
+ __presentation.stage.renderer.forceContextLoss(),
+ );
+ await page.waitForFunction(() => __presentation.stage.lost);
+ await page.evaluate(() =>
+ __presentation.stage.renderer.forceContextRestore(),
+ );
+ await page.waitForFunction(() => !__presentation.stage.lost);
+ await page.waitForFunction(
+ () => __presentation.stage.renderer.info.render.triangles > 0,
+ );
+ await page.evaluate(() => __presentation.stage.dispose());
+ assert.equal(
+ await page.evaluate(() => __presentation.stage.disposed),
+ true,
+ );
+ assert.deepEqual(errors, [], `${game}: console/startup errors`);
+ console.log(`PASS ${game}: ${JSON.stringify(report)}`);
+ await page.close();
+ }
+ } finally {
+ await browser.close();
+ }
+})().catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+});
diff --git a/tests/native-renderers.static.js b/tests/native-renderers.static.js
new file mode 100644
index 0000000..abb63d3
--- /dev/null
+++ b/tests/native-renderers.static.js
@@ -0,0 +1,90 @@
+"use strict";
+const assert = require("node:assert/strict"),
+ fs = require("node:fs"),
+ path = require("node:path");
+const root = path.resolve(__dirname, "..");
+const games = [
+ "BLACKSTAR_AGA",
+ "CARTRIDGE_ZERO",
+ "PIXELWARFRONT",
+ "VECTOR_ZERO",
+ "SEEDSTORM",
+ "SUBSPACE",
+];
+for (const game of games) {
+ const html = fs.readFileSync(path.join(root, game, "index.html"), "utf8");
+ const scripts = [...html.matchAll(/