Served with isolation headers; start-screen capability notices; tests on the real GPU (t88 phase 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:11:42 -04:00
co-authored by Claude Opus 5.5
parent 9781aa3ee1
commit b2c9ad9533
22 changed files with 376 additions and 91 deletions
+24 -4
View File
@@ -15,8 +15,28 @@ python3 build.py
python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes
``` ```
Opening `dist/index.html` directly works too. The game is one self-contained page with no The game must be served: use `python3 tools/serve.py` locally, or serve `dist/` with any web
downloads; sound and graphics are all generated in code. Saves stay in your browser. server that sends these headers. Opening `dist/index.html` from disk is no longer supported.
Use HTTPS when hosting remotely (localhost works over HTTP).
nginx, in the site's `server` or `location` block:
```nginx
add_header Cross-Origin-Opener-Policy same-origin always;
add_header Cross-Origin-Embedder-Policy require-corp always;
```
Caddy, in the site block alongside `root * /path/to/dist` and `file_server`:
```caddyfile
header {
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Embedder-Policy require-corp
}
```
These are plain static files, so a small server is enough. The game is one self-contained page
with no downloads; sound and graphics are generated in code. Saves stay in your browser.
- To keep or move a world, use Pause → Export world file. - To keep or move a world, use Pause → Export world file.
- To load one, use Settings → Load a world file on the title screen, or Pause → Import world file - To load one, use Settings → Load a world file on the title screen, or Pause → Import world file
during a game. World files exported from the claude.ai version load here too. during a game. World files exported from the claude.ai version load here too.
@@ -36,11 +56,11 @@ You need Node (tested with 22), Python 3, and Playwright for the browser tests a
```sh ```sh
uv venv .venv uv venv .venv
uv pip install --python .venv/bin/python -r requirements.txt uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python -m playwright install chromium .venv/bin/python -m playwright install chromium firefox
``` ```
If uv is not installed, `python3 -m venv .venv` and If uv is not installed, `python3 -m venv .venv` and
`.venv/bin/pip install -r requirements.txt` also work; then install Chromium as above. `.venv/bin/pip install -r requirements.txt` also work; then install the browsers as above.
After that, plain `python3` works for every command below; the browser scripts switch to After that, plain `python3` works for every command below; the browser scripts switch to
`.venv` on their own. `.venv` on their own.
+1 -1
View File
@@ -6,7 +6,7 @@ code) into one classic script, so they share one global scope and 'use strict' a
src/sim/base.js applies to all of it. No bundler, no dependencies. src/sim/base.js applies to all of it. No bundler, no dependencies.
Writes: Writes:
dist/index.html the full page: open it, or serve with python3 tools/serve.py dist/index.html the full page: serve with python3 tools/serve.py
dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a
claude.ai artifact) that wrap the page themselves claude.ai artifact) that wrap the page themselves
dist/bundle-lines.json where each source file starts in dist/index.html, so a line number dist/bundle-lines.json where each source file starts in dist/index.html, so a line number
+11 -6
View File
@@ -19,8 +19,9 @@ src/render/ drawing: the pixel look (CPU) and the smooth look (WebGL2)
src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves, src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves,
audio, the frame loop audio, the frame loop
test/ headless suites (t_*.js) and their harness; browser tests (play.py, test/ headless suites (t_*.js) and their harness; browser tests (play.py,
t_smooth.py, t_dev_console.py); run_all.py t_smooth.py, t_dev_console.py, t_caps.py, firefox_smoke.py); run_all.py
tools/ serve.py (build, serve, rebuild on change), server.py (isolated HTTP), shot.py (screenshots), tools/ serve.py (build, serve, rebuild on change), server.py (isolated HTTP), shot.py (screenshots),
browser.py (shared launch/GPU choice), caps.py (capability report),
where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
scripts to .venv), scenes/ (screenshot setups), experiments/ scripts to .venv), scenes/ (screenshot setups), experiments/
(measurements and reports) (measurements and reports)
@@ -441,10 +442,10 @@ Game shell (src/ui/): browser only. This file: settings, the GAME state object,
- functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus - functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $ - variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $
### src/ui/title.js (46 lines) ### src/ui/title.js (74 lines)
The title screen art. Startup capabilities and title art.
- functions: buildTitleCells, drawTitle - functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle
- variables: FONT5, TITLE - variables: REQUIRE_GPU_AND_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE
### src/ui/saves.js (224 lines) ### src/ui/saves.js (224 lines)
Saves in IndexedDB, world files (export / import) and the blueprint library. Saves in IndexedDB, world files (export / import) and the blueprint library.
@@ -456,7 +457,7 @@ Shared player state; the world save remains a complete standalone fallback.
- functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab - functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab
- variables: career, claimBusy - variables: career, claimBusy
### src/ui/session.js (158 lines) ### src/ui/session.js (160 lines)
Starting, loading and quitting a game; modal dialogs; the pause menu. Starting, loading and quitting a game; modal dialogs; the pause menu.
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds - functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
@@ -564,6 +565,7 @@ The frame loop and boot.
## Tests and tools ## Tests and tools
- `test/firefox_smoke.py`: An isolated Firefox title screen and three seconds of a new pixel world.
- `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent) - `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent)
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script. - `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'. - `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
@@ -571,6 +573,7 @@ The frame loop and boot.
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports. - `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
- `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load. - `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
- `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
- `test/t_caps.py`: Capability detection, title warnings and the future startup gate.
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters. - `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen. - `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen.
- `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime. - `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime.
@@ -601,6 +604,8 @@ The frame loop and boot.
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds. - `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing. - `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
- `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains. - `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
- `tools/browser.py`: Shared isolated HTTP sessions; callers attach hooks before navigating to url.
- `tools/caps.py`: Print isolation, WebGPU adapter info and GL renderer for installed browsers.
- `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md. - `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md.
- `tools/perf-measure.js`: Temporary wrappers live only in the profiling page, never in the simulation bundle. - `tools/perf-measure.js`: Temporary wrappers live only in the profiling page, never in the simulation bundle.
- `tools/perf-scenes.js`: Seeded worlds, no storage round-trip (saves quantize heat and land flying grains). - `tools/perf-scenes.js`: Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
+31 -10
View File
@@ -71,8 +71,30 @@ diff -r /tmp/before /tmp/after
to pixels cleanly, and that the thermal lens works. to pixels cleanly, and that the thermal lens works.
- Run them one at a time. Two headless browsers at once can starve each other, and - Run them one at a time. Two headless browsers at once can starve each other, and
`t_smooth.py`'s half-resolution check then fails. `run_all.py` runs them one after the other. `t_smooth.py`'s half-resolution check then fails. `run_all.py` runs them one after the other.
- Rebuild first (`python3 build.py`). They load `dist/index.html`. `run_all.py --browser` - Rebuild first (`python3 build.py`); `run_all.py --browser` rebuilds for you. All browser tools
rebuilds for you. use `tools/browser.py` and `tools/server.py`: a free-port background HTTP server serves the
built page with COOP/COEP headers and shuts down after the browser. No `file://` loads.
- Choose Chromium graphics with `--gpu real|soft` or `DEEPGRAIN_GPU=real|soft` (the command line
wins). This works for the suite runner, individual browser tests, screenshots and profiler.
Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader and sets
`window.__allowSoftwareGL` before load. The explicit software-fallback test still uses soft
mode with that override off. Firefox uses its own default graphics backend.
The default is **real**, including the profiler and `caps.py`. No colour tolerances were
changed. The play test holds the slow-save fixture open during its screenshot, waits for
tool/selection UI updates, disables camera lead after testing it, and gives the gamepad drill
a known reachable target; these checks do not depend on GPU speed or earlier digging.
- `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for
Chromium and installed Firefox. Real mode requires a hardware Chromium adapter.
- `test/t_caps.py` checks missing, null and rejected WebGPU adapters, both title notices, warning
mode and the future `REQUIRE_GPU_AND_THREADS` startup gate. Its forced notice screenshot is
`shots/t88-p1-notice.png`.
- Install Firefox with `.venv/bin/python -m playwright install firefox`.
`python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world,
runs three seconds and checks page errors. `run_all.py --browser` includes it and prints a
skip note if Firefox is absent; an installed browser that fails to launch is a failure.
- Linux WebGPU currently needs Firefox Nightly; the notice points to `about:config` →
`dom.webgpu.enabled`. Sources: [Mozilla's experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webgpu_api)
and [GPUWeb implementation status](https://github.com/gpuweb/gpuweb/wiki/Implementation-Status#firefox).
## Screenshots: tools/shot.py ## Screenshots: tools/shot.py
@@ -106,10 +128,9 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
## Pitfalls ## Pitfalls
- **WebGL in headless Chromium.** The browser tests use SwiftShader for portable graphics checks: - **GPU access in headless Chromium.** Real mode needs access to GPU devices and driver caches;
launch with `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`. Also set a filesystem sandbox can silently force software rendering. Use `tools/caps.py` to check.
`window.__allowSoftwareGL = true` before the page loads (`add_init_script`); otherwise the game Soft mode is available for machines without a GPU. Both modes load isolated HTTP pages.
sees software rendering and switches to the pixel look. `tools/shot.py` does both.
- **Top-level `const` and `let` are not `window` properties.** In the page, `window.SM` is - **Top-level `const` and `let` are not `window` properties.** In the page, `window.SM` is
`undefined` even though `SM` exists. Write `typeof SM !== 'undefined'`, or use the bare name. `undefined` even though `SM` exists. Write `typeof SM !== 'undefined'`, or use the bare name.
Functions declared with `function` are on `window`. Functions declared with `function` are on `window`.
@@ -176,12 +197,12 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change. tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change.
Performance runs launch Chromium with Performance runs launch Chromium with
`--use-angle=vulkan --enable-features=Vulkan --enable-gpu --ignore-gpu-blocklist --enable-gpu-rasterization`. `--use-angle=vulkan --enable-features=Vulkan,VulkanFromANGLE --enable-gpu --ignore-gpu-blocklist --enable-gpu-rasterization --enable-unsafe-webgpu`.
They record the unmasked GPU/renderer, cores, browser version, viewport/world size, They record the unmasked GPU/renderer, cores, browser version, viewport/world size,
`crossOriginIsolated`, `navigator.gpu`, source hash and elapsed time. They refuse software `crossOriginIsolated`, `navigator.gpu`, source hash and elapsed time. Real mode refuses software
renderers or missing isolation. Chromium needs access to the host GPU devices; a filesystem renderers; both modes require isolation. Chromium needs access to the host GPU devices; a filesystem
sandbox that hides `/dev/dri`/NVIDIA devices must be lifted for this measurement. The browser sandbox that hides `/dev/dri`/NVIDIA devices must be lifted for this measurement. The browser
tests' SwiftShader numbers are not a performance baseline. tests' SwiftShader numbers (and explicit `--gpu soft` profiler runs) are not a hardware performance baseline.
The existing in-game Performance stats toggle shows smoothed CPU world/light/overlay times The existing in-game Performance stats toggle shows smoothed CPU world/light/overlay times
and movement/heat/rest per simulation tick. Detailed renderer clocks are off by default and movement/heat/rest per simulation tick. Detailed renderer clocks are off by default
+13
View File
@@ -4,6 +4,19 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-27: t88 phase 1, serving and capability checks (Claude Code + Codex)
- The game must be served with COOP/COEP (README has nginx and Caddy lines; opening from disk is
dropped). The title screen checks `crossOriginIsolated` and a WebGPU adapter (`CAPS`,
`checkCapabilities` in `src/ui/title.js`) and shows a notice; warnings only until
`REQUIRE_GPU_AND_THREADS` is flipped in Phases 2-3.
- Browser tests and tools load through `tools/browser.py` + `tools/server.py`, on the real GPU by
default (`--gpu soft` / `DEEPGRAIN_GPU=soft` for SwiftShader). `tools/caps.py` prints isolation
and WebGPU per browser; `test/firefox_smoke.py` runs in `run_all.py --browser` when Firefox is
installed. Chromium 156: isolated, WebGPU on the RTX 5070. Playwright Firefox 157: isolated, no
WebGPU (Linux).
- Tests: `python3 test/run_all.py --browser` all passed on the real GPU.
## 2026-10-27: t88 phase 0, profiler and baseline (Claude Code + Codex) ## 2026-10-27: t88 phase 0, profiler and baseline (Claude Code + Codex)
- `tools/perf.py` rewritten: real GPU (ANGLE Vulkan flags), served by the new `tools/server.py` - `tools/perf.py` rewritten: real GPU (ANGLE Vulkan flags), served by the new `tools/server.py`
+1
View File
@@ -48,6 +48,7 @@
<div class="titlewrap"> <div class="titlewrap">
<canvas id="titleart" width="320" height="80" aria-label="Deepgrain Foundry"></canvas> <canvas id="titleart" width="320" height="80" aria-label="Deepgrain Foundry"></canvas>
<p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p> <p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p>
<div id="cap-notice" class="plate note" role="status" hidden></div>
<div class="tmenu" id="tmenu"> <div class="tmenu" id="tmenu">
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button> <button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
<button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button> <button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button>
+2
View File
@@ -291,6 +291,8 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
#titleart { width: min(820px, 100%); aspect-ratio: 4 / 1; display: block; margin: 0 auto; image-rendering: pixelated; max-width: 100%; } #titleart { width: min(820px, 100%); aspect-ratio: 4 / 1; display: block; margin: 0 auto; image-rendering: pixelated; max-width: 100%; }
.titlewrap { align-self: center; display: flex; flex-direction: column; align-items: center; gap: 18px; padding-block: 24px; } .titlewrap { align-self: center; display: flex; flex-direction: column; align-items: center; gap: 18px; padding-block: 24px; }
.tagline { color: var(--ash); max-width: 52ch; text-align: center; margin: -6px 0 4px; text-wrap: balance; } .tagline { color: var(--ash); max-width: 52ch; text-align: center; margin: -6px 0 4px; text-wrap: balance; }
#cap-notice { max-width: 64ch; padding: 8px 14px; color: var(--melt-hot); }
#cap-notice p { margin: 6px 0; }
.tmenu { display: flex; flex-direction: column; gap: 8px; width: min(360px, 100%); } .tmenu { display: flex; flex-direction: column; gap: 8px; width: min(360px, 100%); }
.tmenu .btn { padding: 10px 14px; text-align: left; display: flex; justify-content: space-between; align-items: center; } .tmenu .btn { padding: 10px 14px; text-align: left; display: flex; justify-content: space-between; align-items: center; }
.tmenu .btn small { color: var(--ash); font-size: 0.82em; } .tmenu .btn small { color: var(--ash); font-size: 0.82em; }
+2
View File
@@ -32,6 +32,7 @@ async function setupNewWorld(seed, w, h, name, carried, title = 'Generating worl
if (carried) applyCareerState(carried); if (carried) applyCareerState(carried);
} }
async function startNewWorld(seed, w, h, name = '') { async function startNewWorld(seed, w, h, name = '') {
if (!await canStartGame()) return false;
try { try {
const worlds = await listWorlds(); const worlds = await listWorlds();
await setupNewWorld(seed, w, h, cleanWorldName(name, claimName(seed, worlds.map(w => w.name)))); await setupNewWorld(seed, w, h, cleanWorldName(name, claimName(seed, worlds.map(w => w.name))));
@@ -45,6 +46,7 @@ async function startNewWorld(seed, w, h, name = '') {
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); } } catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
} }
async function startFromSave(sv, onProgress) { async function startFromSave(sv, onProgress) {
if (!await canStartGame()) return false;
onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world')); onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world'));
auInit(); auInit();
GAME.paused = true; GAME.paused = true;
+29 -1
View File
@@ -1,4 +1,32 @@
// The title screen art. // Startup capabilities and title art.
const REQUIRE_GPU_AND_THREADS = false;
const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false };
async function checkCapabilities() {
try {
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
CAPS.webgpu = !!adapter;
const info = adapter && adapter.info;
if (info) CAPS.adapterInfo = { vendor: info.vendor, architecture: info.architecture, device: info.device, description: info.description };
} catch (e) { CAPS.webgpu = false; }
CAPS.ready = true;
renderCapabilityNotice();
}
function renderCapabilityNotice() {
const messages = [];
if (!CAPS.isolated) messages.push("Deepgrain needs to be served (for example with python3 tools/serve.py) so it can use all CPU cores; opened from a file it can't.");
if (!CAPS.webgpu) messages.push(/Firefox\//.test(navigator.userAgent)
? 'WebGPU is unavailable. In Firefox, open about:config and set dom.webgpu.enabled to true, then restart. On Linux, use Firefox Nightly. If no graphics adapter is available, try Chrome or Edge.'
: 'WebGPU is unavailable. Use a browser with WebGPU (Chrome or Edge).');
const notice = $('cap-notice');
notice.replaceChildren(...messages.map(text => { const p = document.createElement('p'); p.textContent = text; return p; }));
notice.hidden = !messages.length;
}
async function canStartGame() {
await CAPS_CHECK;
return !REQUIRE_GPU_AND_THREADS || (CAPS.isolated && CAPS.webgpu);
}
const CAPS_CHECK = checkCapabilities();
// ---------- Title art ---------- // ---------- Title art ----------
const FONT5 = { const FONT5 = {
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
+38
View File
@@ -0,0 +1,38 @@
"""An isolated Firefox title screen and three seconds of a new pixel world."""
import json
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright
ensure_playwright()
from browser import capabilities, consume_gpu_option, installed, launch
consume_gpu_option()
if not installed('firefox'):
print('SKIP firefox_smoke.py: Playwright Firefox is not installed')
sys.exit(0)
with launch(browser_name='firefox') as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.add_init_script("localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));")
page.goto(url)
caps = capabilities(page)
print('Firefox: ' + json.dumps(caps))
assert caps['isolated']
if not caps['webgpu']:
assert page.locator('#cap-notice').is_visible()
text = page.inner_text('#cap-notice')
assert 'about:config' in text and 'dom.webgpu.enabled' in text and 'Firefox Nightly' in text
print('PASS Firefox isolation and capability notice')
page.click('#btn-new')
page.fill('#nw-seed', '424242')
page.select_option('#nw-size', '2048x1024')
page.click('#btn-generate')
page.wait_for_function('GAME.running && !GAME.loading', timeout=60000)
before = page.evaluate('tick')
page.wait_for_timeout(3000)
assert page.evaluate('tick') > before
assert not errors, errors
print('PASS Firefox new world runs for three seconds without page errors')
+28 -15
View File
@@ -4,20 +4,19 @@
import json, sys, pathlib, re import json, sys, pathlib, re
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools')) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright() from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright from browser import launch, consume_gpu_option
consume_gpu_option()
root = pathlib.Path(__file__).resolve().parent.parent root = pathlib.Path(__file__).resolve().parent.parent
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'play'); out.mkdir(parents=True, exist_ok=True) out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'play'); out.mkdir(parents=True, exist_ok=True)
url = (root / 'dist' / 'index.html').as_uri()
fails = [] fails = []
def check(name, cond, extra=''): def check(name, cond, extra=''):
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else '')) print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
if not cond: fails.append(name) if not cond: fails.append(name)
with sync_playwright() as p: with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
b = p.chromium.launch() ctx = pg.context
ctx = b.new_context(viewport={'width': 1280, 'height': 800})
pg = ctx.new_page()
logs = [] logs = []
pg.add_init_script("if (!localStorage.getItem('deepgrain.settings')) localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));")
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None) pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None)
pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300) pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300)
@@ -61,13 +60,13 @@ with sync_playwright() as p:
check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId')) check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId'))
pg.evaluate("""() => { pg.evaluate("""() => {
window.__pack = packBuffers; window.__pack = packBuffers;
packBuffers = async b => { await new Promise(r => setTimeout(r, 600)); return __pack(b); }; packBuffers = async b => { await new Promise(r => { window.__finishSave = r; }); return __pack(b); };
window.__saving = saveWorld('main', true); window.__saving = saveWorld('main', true);
}""") }""")
pg.locator('#saving').wait_for(state='visible') pg.locator('#saving').wait_for(state='visible')
pg.screenshot(path=str(out / 'saving-progress.png')) pg.screenshot(path=str(out / 'saving-progress.png'))
check('slow save shows the saving chip', pg.is_visible('#saving')) check('slow save shows the saving chip', pg.is_visible('#saving'))
pg.evaluate('async () => { await __saving; packBuffers = __pack; }') pg.evaluate('async () => { __finishSave(); await __saving; packBuffers = __pack; }')
check('saving chip hides when the save ends', not pg.is_visible('#saving')) check('saving chip hides when the save ends', not pg.is_visible('#saving'))
pg.evaluate('quitToTitle()') pg.evaluate('quitToTitle()')
names = {} names = {}
@@ -248,7 +247,8 @@ with sync_playwright() as p:
led = ev(camera_offset) led = ev(camera_offset)
check('camera leads toward the pointer when enabled', all(b - a > 2 for a, b in zip(centred, led)), led) check('camera leads toward the pointer when enabled', all(b - a > 2 for a, b in zip(centred, led)), led)
pg.mouse.move(*mouse) pg.mouse.move(*mouse)
ev(f'GAME.paused = {json.dumps(paused)}; saveSettings()') # Keep world-to-screen targets fixed after checking both camera modes.
ev(f'GAME.paused = {json.dumps(paused)}; SET.cameraLead = false; saveSettings(); snapCamera()')
def cell_to_screen(x, y): def cell_to_screen(x, y):
return ev(f'(() => {{ const dpr = R.canvas.width / innerWidth; const s = worldToScreenPx({x}+0.5, {y}+0.5); return [s.x/dpr, s.y/dpr]; }})()') return ev(f'(() => {{ const dpr = R.canvas.width / innerWidth; const s = worldToScreenPx({x}+0.5, {y}+0.5); return [s.x/dpr, s.y/dpr]; }})()')
def blk(bx, by): return cell_to_screen(bx * 4 + 2, by * 4 + 2) def blk(bx, by): return cell_to_screen(bx * 4 + 2, by * 4 + 2)
@@ -281,7 +281,11 @@ with sync_playwright() as p:
# 1. drill: rock breaks loose, nothing enters the bag # 1. drill: rock breaks loose, nothing enters the bag
pg.mouse.move(*cell_to_screen(bx + 6, gy + 3)); settle(900) pg.mouse.move(*cell_to_screen(bx + 6, gy + 3)); settle(900)
sx, sy = cell_to_screen(bx + 6, gy + 3) sx, sy = cell_to_screen(bx + 6, gy + 3)
hold(sx, sy, 3500) pg.mouse.move(sx, sy); pg.mouse.down()
try:
pg.wait_for_function('stats.dug > 20', timeout=15000)
finally:
pg.mouse.up()
settle(700) settle(700)
check('drill digs with the mouse', ev('stats.dug') > 20, ev('stats.dug')) check('drill digs with the mouse', ev('stats.dug') > 20, ev('stats.dug'))
check('drill does not fill the bag', ev('bagTotal') == 0, ev('bagTotal')) check('drill does not fill the bag', ev('bagTotal') == 0, ev('bagTotal'))
@@ -307,7 +311,8 @@ with sync_playwright() as p:
ev("SET.cardSeconds = 0; $('cards').replaceChildren()") # centred cards cover the mouse work area; discovery was checked above ev("SET.cardSeconds = 0; $('cards').replaceChildren()") # centred cards cover the mouse work area; discovery was checked above
# 2. vacuum, left button: lock onto what you click # 2. vacuum, left button: lock onto what you click
pg.keyboard.press('2'); settle(100) pg.keyboard.press('2')
pg.wait_for_function("GAME.tool === 1 && $('selcard').querySelector('.name')?.textContent === 'Pneumatic nozzle'")
check('Pneumatic nozzle card title and width match the selected tool', ev("GAME.tool === 1 && $('selcard').querySelector('.name').textContent === 'Pneumatic nozzle' && $('selcard').firstElementChild.querySelector('.chip').textContent === `width ${GAME.brush[1]}` && !$('selcard').querySelector('.sub .chip')")) check('Pneumatic nozzle card title and width match the selected tool', ev("GAME.tool === 1 && $('selcard').querySelector('.name').textContent === 'Pneumatic nozzle' && $('selcard').firstElementChild.querySelector('.chip').textContent === `width ${GAME.brush[1]}` && !$('selcard').querySelector('.sub .chip')"))
check('Pneumatic nozzle name appears in the tool list and hotbar title', ev("TOOLS[1].name === 'Pneumatic nozzle' && $('hotbar').children[1].title.startsWith('Pneumatic nozzle')")) check('Pneumatic nozzle name appears in the tool list and hotbar title', ev("TOOLS[1].name === 'Pneumatic nozzle' && $('hotbar').children[1].title.startsWith('Pneumatic nozzle')"))
tgt = ev(f'''(() => {{ for (let r = 0; r < 30; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{ tgt = ev(f'''(() => {{ for (let r = 0; r < 30; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
@@ -492,6 +497,7 @@ with sync_playwright() as p:
check('Shift lights every loose bag row', ev("blowAllList().every(m => [...$('baglist').querySelectorAll('.bagrow.on .nm')].some(e => e.textContent === MD[m].name))")) check('Shift lights every loose bag row', ev("blowAllList().every(m => [...$('baglist').querySelectorAll('.bagrow.on .nm')].some(e => e.textContent === MD[m].name))"))
pg.screenshot(path=str(out / '03b-blow-all-preview.png')) pg.screenshot(path=str(out / '03b-blow-all-preview.png'))
pg.keyboard.up('Shift'); settle(150) pg.keyboard.up('Shift'); settle(150)
pg.wait_for_function("!$('selcard').querySelector('.vacchoice').textContent.includes('Everything') && document.querySelectorAll('#baglist .bagrow.on').length === 1")
check('releasing Shift restores the chosen blow material', ev("$('selcard').querySelector('.vacchoice').textContent.includes(MD[COAL].name) && !$('selcard').querySelector('.vacchoice').textContent.includes('Everything')")) check('releasing Shift restores the chosen blow material', ev("$('selcard').querySelector('.vacchoice').textContent.includes(MD[COAL].name) && !$('selcard').querySelector('.vacchoice').textContent.includes('Everything')"))
check('releasing Shift lights only coal', ev("(() => { const rows = [...$('baglist').querySelectorAll('.bagrow.on')]; return rows.length === 1 && rows[0].querySelector('.nm').textContent === MD[COAL].name; })()")) check('releasing Shift lights only coal', ev("(() => { const rows = [...$('baglist').querySelectorAll('.bagrow.on')]; return rows.length === 1 && rows[0].querySelector('.nm').textContent === MD[COAL].name; })()"))
bot_at = ev('[bot.x, bot.y]') bot_at = ev('[bot.x, bot.y]')
@@ -1156,6 +1162,12 @@ with sync_playwright() as p:
navigator.getGamepads = () => [window.__pad]; navigator.getGamepads = () => [window.__pad];
}''') }''')
pg.keyboard.press('1'); settle(100) pg.keyboard.press('1'); settle(100)
# The preceding play-through can leave only air below the bot.
ev('''() => {
GAME.drillTune = 0;
const x0 = Math.floor(bot.x + bot.w / 2), y0 = Math.ceil(bot.y + bot.h) + 2;
for (let y = y0; y < y0 + 8; y++) for (let x = x0 - 4; x <= x0 + 4; x++) setCell(y * W + x, DIRT, 20, F_NAT);
}''')
d0 = ev('stats.dug') d0 = ev('stats.dug')
ev("__pad.axes[3] = 0.9; __pad.buttons[7].pressed = true; __pad.buttons[7].value = 1;") ev("__pad.axes[3] = 0.9; __pad.buttons[7].pressed = true; __pad.buttons[7].value = 1;")
settle(1500) settle(1500)
@@ -1206,10 +1218,11 @@ with sync_playwright() as p:
pg.keyboard.press('Escape'); settle(150) pg.keyboard.press('Escape'); settle(150)
# Fog has its own paused scene; existing play checks keep the whole world visible. # Fog has its own paused scene; existing play checks keep the whole world visible.
fb = p.chromium.launch(args=['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']) fog_context = b.new_context(viewport={'width': 1280, 'height': 800})
fp = fb.new_page(viewport={'width': 1280, 'height': 800}) fp = fog_context.new_page()
if pg.evaluate('!!window.__allowSoftwareGL'):
fp.add_init_script('window.__allowSoftwareGL = true')
fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}')) fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}'))
fp.add_init_script('window.__allowSoftwareGL = true')
fp.goto(url) fp.goto(url)
fp.evaluate('startNewWorld(4242, 512, 384)') fp.evaluate('startNewWorld(4242, 512, 384)')
fp.wait_for_function('GAME.running') fp.wait_for_function('GAME.running')
@@ -1318,7 +1331,7 @@ with sync_playwright() as p:
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && SM.ok')) check('fog draws in the smooth look', fp.evaluate('R.smoothOn && SM.ok'))
check('unseen smooth grains have no terrain shape', fp.evaluate("(() => {const k=(235-R.vy0)*R.bw+300-R.vx0;return R.px[k]===0x04000000 && R.backPx[k]===0 && R.treePx[k]===0 && R.fieldPx[k]===255})()")) check('unseen smooth grains have no terrain shape', fp.evaluate("(() => {const k=(235-R.vy0)*R.bw+300-R.vx0;return R.px[k]===0x04000000 && R.backPx[k]===0 && R.treePx[k]===0 && R.fieldPx[k]===255})()"))
fp.screenshot(path=str(out / 'fog-smooth.png')) fp.screenshot(path=str(out / 'fog-smooth.png'))
fb.close() fog_context.close()
# 11. the largest world from the title screen # 11. the largest world from the title screen
pg2 = ctx.new_page() pg2 = ctx.new_page()
+7 -1
View File
@@ -30,6 +30,9 @@ def run_py(name):
def main(): def main():
sys.path.insert(0, str(ROOT / 'tools'))
from browser import consume_gpu_option
consume_gpu_option()
args = [a for a in sys.argv[1:] if not a.startswith('--')] args = [a for a in sys.argv[1:] if not a.startswith('--')]
browser = '--browser' in sys.argv or '--browser-only' in sys.argv browser = '--browser' in sys.argv or '--browser-only' in sys.argv
node = '--browser-only' not in sys.argv node = '--browser-only' not in sys.argv
@@ -42,7 +45,8 @@ def main():
if browser: if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True) subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake # one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_smooth.py', 't_dev_console.py']: results.append(run_py(name)) for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 'firefox_smoke.py']:
results.append(run_py(name))
bad = 0 bad = 0
for name, code, out, secs in results: for name, code, out, secs in results:
fails = [l for l in out.splitlines() if l.startswith('FAIL') or 'Error' in l or l.startswith('FAILS: [') and l != 'FAILS: []'] fails = [l for l in out.splitlines() if l.startswith('FAIL') or 'Error' in l or l.startswith('FAILS: [') and l != 'FAILS: []']
@@ -50,6 +54,8 @@ def main():
ok = code == 0 and not [l for l in fails if l.startswith('FAIL ') or 'Error' in l] ok = code == 0 and not [l for l in fails if l.startswith('FAIL ') or 'Error' in l]
bad += not ok bad += not ok
print(f"{'ok ' if ok else 'FAIL'} {name:22s} {passes:3d} checks passed {secs:5.1f}s") print(f"{'ok ' if ok else 'FAIL'} {name:22s} {passes:3d} checks passed {secs:5.1f}s")
for line in out.splitlines():
if line.startswith('SKIP '): print(' ' + line)
if not ok: if not ok:
for l in (fails or out.splitlines()[-15:]): print(' ' + l) for l in (fails or out.splitlines()[-15:]): print(' ' + l)
print('ALL PASSED' if not bad else f'{bad} SUITE(S) FAILED') print('ALL PASSED' if not bad else f'{bad} SUITE(S) FAILED')
+52
View File
@@ -0,0 +1,52 @@
"""Capability detection, title warnings and the future startup gate."""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright
ensure_playwright()
from browser import capabilities, consume_gpu_option, launch
consume_gpu_option()
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'required']:
with launch() as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
if case != 'native':
gpu = 'undefined' if case == 'absent' else "{requestAdapter: async () => null}"
if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}"
page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});")
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
if case == 'firefox':
page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});")
if case == 'required':
def require(route):
response = route.fetch()
route.fulfill(response=response, body=response.text().replace(
'const REQUIRE_GPU_AND_THREADS = false;', 'const REQUIRE_GPU_AND_THREADS = true;'))
page.route('**/index.html', require)
page.goto(url)
caps = capabilities(page)
page.wait_for_function("!$('title').hidden")
if case == 'native':
assert caps['isolated']
assert page.locator('#cap-notice').is_hidden() == caps['webgpu']
else:
assert not caps['webgpu'] and caps['adapterInfo'] is None
notice = page.inner_text('#cap-notice')
assert 'python3 tools/serve.py' in notice and "opened from a file it can't" in notice
assert ('Firefox Nightly' in notice and 'dom.webgpu.enabled' in notice) if case == 'firefox' else 'Chrome or Edge' in notice
if case == 'required':
assert not page.evaluate('canStartGame()')
page.evaluate('startNewWorld(424242, 512, 384)')
assert not page.evaluate('GAME.running || GAME.loading')
assert not page.evaluate('startFromSave({})')
else:
assert page.evaluate('canStartGame()')
if case == 'absent':
Path('shots').mkdir(exist_ok=True)
page.screenshot(path='shots/t88-p1-notice.png')
page.evaluate("SET.look='pixel'; startNewWorld(424242, 512, 384)")
page.wait_for_function('GAME.running && !GAME.loading', timeout=60000)
assert not errors, errors
print(f'PASS capabilities: {case}')
+4 -5
View File
@@ -2,7 +2,8 @@
import pathlib, sys import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools')) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright() from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright from browser import launch, consume_gpu_option
consume_gpu_option()
ROOT = pathlib.Path(__file__).resolve().parent.parent ROOT = pathlib.Path(__file__).resolve().parent.parent
SHOTS = ROOT / 'shots' SHOTS = ROOT / 'shots'
@@ -12,13 +13,11 @@ def check(name, ok):
print(('PASS ' if ok else 'FAIL ') + name) print(('PASS ' if ok else 'FAIL ') + name)
if not ok: fails.append(name) if not ok: fails.append(name)
with sync_playwright() as p: with launch() as (browser, page, url):
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 1280, 'height': 760})
errors = [] errors = []
page.on('pageerror', lambda e: errors.append(str(e))) page.on('pageerror', lambda e: errors.append(str(e)))
page.add_init_script("localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));") page.add_init_script("localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));")
page.goto((ROOT / 'dist/index.html').as_uri()) page.goto(url)
page.wait_for_function("!$('title').hidden") page.wait_for_function("!$('title').hidden")
page.evaluate("startNewWorld(424242, 512, 384, 'Console test')") page.evaluate("startNewWorld(424242, 512, 384, 'Console test')")
page.wait_for_function('GAME.running && !GAME.loading') page.wait_for_function('GAME.running && !GAME.loading')
+13 -13
View File
@@ -2,21 +2,20 @@
import sys, pathlib import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools')) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright() from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright from browser import launch, consume_gpu_option
consume_gpu_option()
root = pathlib.Path(__file__).resolve().parent.parent root = pathlib.Path(__file__).resolve().parent.parent
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'smooth'); out.mkdir(parents=True, exist_ok=True) out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'smooth'); out.mkdir(parents=True, exist_ok=True)
fails = [] fails = []
def check(name, cond, extra=''): def check(name, cond, extra=''):
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else '')) print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
if not cond: fails.append(name) if not cond: fails.append(name)
with sync_playwright() as p: with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
b = p.chromium.launch(args=['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'])
pg = b.new_page(viewport={'width': 1024, 'height': 640})
logs = [] logs = []
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type in ('error', 'warning') else None) pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type in ('error', 'warning') else None)
pg.add_init_script('window.__allowSoftwareGL = true; localStorage.clear();') pg.add_init_script('localStorage.clear();')
pg.goto((root / 'dist' / 'index.html').as_uri()); pg.evaluate('SET.fog = false'); pg.wait_for_timeout(300) pg.goto(url); pg.evaluate('SET.fog = false'); pg.wait_for_timeout(300)
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate') pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500) pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500)
ev = pg.evaluate ev = pg.evaluate
@@ -283,15 +282,16 @@ with sync_playwright() as p:
}""") }""")
check('filter drag previews pass stored travel bits to the join rule', check('filter drag previews pass stored travel bits to the join rule',
bool(filter_plans) and all(v == (128 if c == ev('BP_FILTER_DOWN') else 0) for c, v in filter_plans), filter_plans) bool(filter_plans) and all(v == (128 if c == ev('BP_FILTER_DOWN') else 0) for c, v in filter_plans), filter_plans)
# a browser without hardware drawing gets the pixel look and a note
pg2 = b.new_page(viewport={'width': 800, 'height': 500})
pg2.add_init_script('localStorage.clear();')
pg2.goto((root / 'dist' / 'index.html').as_uri()); pg2.evaluate('SET.fog = false'); pg2.wait_for_timeout(300)
pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate')
pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200)
check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]'))
errs = [l for l in logs if 'PAGEERROR' in l or l.startswith('error')] errs = [l for l in logs if 'PAGEERROR' in l or l.startswith('error')]
check('no errors', not errs, errs[:3]) check('no errors', not errs, errs[:3])
b.close() b.close()
# a browser without hardware drawing gets the pixel look and a note
with launch(gpu='soft', viewport={'width': 800, 'height': 500}) as (_, pg2, soft_url):
pg2.add_init_script('window.__allowSoftwareGL = false; localStorage.clear();')
pg2.goto(soft_url); pg2.evaluate('SET.fog = false'); pg2.wait_for_timeout(300)
pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate')
pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200)
check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]'))
print('FAILS:', fails) print('FAILS:', fails)
sys.exit(1 if fails else 0) sys.exit(1 if fails else 0)
+65
View File
@@ -0,0 +1,65 @@
"""Shared isolated HTTP sessions; callers attach hooks before navigating to url."""
import argparse
from contextlib import contextmanager
import os
from pathlib import Path
import sys
from urllib.parse import quote
from server import DIST, isolated_server
GPU_ARGS = ['--use-angle=vulkan', '--enable-features=Vulkan,VulkanFromANGLE', '--enable-gpu',
'--ignore-gpu-blocklist', '--enable-gpu-rasterization', '--enable-unsafe-webgpu']
SOFT_ARGS = ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']
DEFAULT_GPU = 'real'
def gpu_option(parser):
parser.add_argument('--gpu', choices=['real', 'soft'],
default=os.environ.get('DEEPGRAIN_GPU', DEFAULT_GPU))
def consume_gpu_option():
"""Keep the older scripts' positional arguments."""
parser = argparse.ArgumentParser(add_help=False)
gpu_option(parser)
options, remaining = parser.parse_known_args()
os.environ['DEEPGRAIN_GPU'] = options.gpu
sys.argv[1:] = remaining
return options.gpu
@contextmanager
def launch(*, gpu=None, browser_name='chromium', viewport=None, page_path=None, args=()):
from playwright.sync_api import sync_playwright
gpu = gpu or os.environ.get('DEEPGRAIN_GPU', DEFAULT_GPU)
if gpu not in ('real', 'soft'):
raise ValueError('GPU must be real or soft')
path = Path(page_path).resolve() if page_path else DIST / 'index.html'
with isolated_server(path.parent) as base, sync_playwright() as p:
flags = (GPU_ARGS if gpu == 'real' else SOFT_ARGS) if browser_name == 'chromium' else []
browser = getattr(p, browser_name).launch(args=[*flags, *args])
try:
context = browser.new_context(viewport=viewport or {'width': 1280, 'height': 760})
page = context.new_page()
if gpu == 'soft':
page.add_init_script('window.__allowSoftwareGL = true;')
yield browser, page, base + '/' + quote(path.name)
finally:
browser.close()
def installed(browser_name):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
return Path(getattr(p, browser_name).executable_path).is_file()
def capabilities(page):
page.wait_for_function('CAPS.ready')
return page.evaluate("""() => {
const gl = document.createElement('canvas').getContext('webgl2');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
return { isolated: CAPS.isolated, webgpu: CAPS.webgpu, adapterInfo: CAPS.adapterInfo,
renderer: gl ? gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER) : null };
}""")
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Print isolation, WebGPU adapter info and GL renderer for installed browsers."""
import argparse
import json
from use_venv import ensure_playwright
ensure_playwright()
from browser import capabilities, gpu_option, installed, launch
def main():
parser = argparse.ArgumentParser(description=__doc__)
gpu_option(parser)
options = parser.parse_args()
for name in ['chromium', 'firefox']:
if not installed(name):
print(f'SKIP {name}: Playwright browser is not installed')
continue
with launch(gpu=options.gpu, browser_name=name) as (browser, page, url):
page.goto(url)
caps = capabilities(page)
print(f'{name} {browser.version}: {json.dumps(caps)}', flush=True)
assert caps['isolated'], 'Server did not isolate the page'
if name == 'chromium' and options.gpu == 'real':
assert caps['webgpu'] and caps['adapterInfo'] is not None, 'No headless WebGPU adapter'
assert caps['adapterInfo']['architecture'] != 'swiftshader', 'Software WebGPU in real mode'
assert not any(s in (caps['renderer'] or '').lower() for s in ['swiftshader', 'llvmpipe', 'softpipe']), 'Software GL in real mode'
if __name__ == '__main__':
main()
+7 -10
View File
@@ -11,12 +11,10 @@ import subprocess
import sys import sys
import time import time
from server import isolated_server from browser import launch, gpu_option, GPU_ARGS, SOFT_ARGS
from use_venv import ensure_playwright from use_venv import ensure_playwright
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
GPU_ARGS = ['--use-angle=vulkan', '--enable-features=Vulkan', '--enable-gpu',
'--ignore-gpu-blocklist', '--enable-gpu-rasterization']
def size(value): def size(value):
@@ -107,6 +105,7 @@ def main():
ap.add_argument('--seconds', type=float, default=10) ap.add_argument('--seconds', type=float, default=10)
ap.add_argument('--json', type=Path) ap.add_argument('--json', type=Path)
ap.add_argument('--compare', type=Path) ap.add_argument('--compare', type=Path)
gpu_option(ap)
args = ap.parse_args() args = ap.parse_args()
try: try:
zooms = list(dict.fromkeys(float(z) for z in args.zooms.split(','))) zooms = list(dict.fromkeys(float(z) for z in args.zooms.split(',')))
@@ -129,7 +128,6 @@ def main():
if baseline and (baseline.get('schema') != 1 or baseline['options'] != options): if baseline and (baseline.get('schema') != 1 or baseline['options'] != options):
ap.error('baseline schema/options differ; use the same matrix, dimensions and seconds') ap.error('baseline schema/options differ; use the same matrix, dimensions and seconds')
ensure_playwright() ensure_playwright()
from playwright.sync_api import sync_playwright
started = time.monotonic() started = time.monotonic()
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True, stdout=sys.stderr) subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True, stdout=sys.stderr)
# Derive coverage from the source so a newly added sim step cannot silently disappear. # Derive coverage from the source so a newly added sim step cannot silently disappear.
@@ -141,13 +139,12 @@ def main():
# boot's title draw is outside frame(). # boot's title draw is outside frame().
overlays.remove('drawTitle') overlays.remove('drawTitle')
result = dict(schema=1, created_utc=datetime.now(timezone.utc).isoformat(), options=options, result = dict(schema=1, created_utc=datetime.now(timezone.utc).isoformat(), options=options,
gpu_args=GPU_ARGS, scenes={}, sim_targets=steps, overlay_targets=overlays) gpu_args=GPU_ARGS if args.gpu == 'real' else SOFT_ARGS, scenes={}, sim_targets=steps, overlay_targets=overlays)
files = sorted((ROOT / 'src').rglob('*')) + [Path(__file__), ROOT / 'tools/perf-scenes.js', ROOT / 'tools/perf-measure.js'] files = sorted((ROOT / 'src').rglob('*')) + [Path(__file__), ROOT / 'tools/browser.py', ROOT / 'tools/server.py',
ROOT / 'tools/perf-scenes.js', ROOT / 'tools/perf-measure.js']
result['source_sha256'] = hashlib.sha256(b''.join(p.read_bytes() for p in files if p.is_file())).hexdigest() result['source_sha256'] = hashlib.sha256(b''.join(p.read_bytes() for p in files if p.is_file())).hexdigest()
errors = [] errors = []
with isolated_server() as url, sync_playwright() as p: with launch(gpu=args.gpu, viewport=dict(width=args.viewport[0], height=args.viewport[1])) as (browser, pg, url):
browser = p.chromium.launch(args=GPU_ARGS)
pg = browser.new_page(viewport=dict(width=args.viewport[0], height=args.viewport[1]))
pg.on('pageerror', lambda e: errors.append(str(e))) pg.on('pageerror', lambda e: errors.append(str(e)))
pg.add_init_script('''window.__perfStop = true; pg.add_init_script('''window.__perfStop = true;
const raf = window.requestAnimationFrame.bind(window), pending = new Set(); const raf = window.requestAnimationFrame.bind(window), pending = new Set();
@@ -169,7 +166,7 @@ def main():
}''') }''')
if not result['machine']['crossOriginIsolated']: if not result['machine']['crossOriginIsolated']:
raise RuntimeError('Local server did not provide cross-origin isolation') raise RuntimeError('Local server did not provide cross-origin isolation')
if re.search('swiftshader|llvmpipe|softpipe|software', result['machine']['renderer'], re.I): if args.gpu == 'real' and re.search('swiftshader|llvmpipe|softpipe|software', result['machine']['renderer'], re.I):
raise RuntimeError('Refusing a software-rendered baseline') raise RuntimeError('Refusing a software-rendered baseline')
result['machine']['browser_version'] = browser.version result['machine']['browser_version'] = browser.version
def setup(scene): def setup(scene):
+3 -5
View File
@@ -2,16 +2,14 @@
import sys, time, pathlib import sys, time, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from use_venv import ensure_playwright; ensure_playwright() from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright from browser import launch, consume_gpu_option
consume_gpu_option()
root = pathlib.Path(__file__).resolve().parent.parent root = pathlib.Path(__file__).resolve().parent.parent
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'playthrough') out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'playthrough')
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)
url = (root / 'dist' / 'index.html').as_uri()
with sync_playwright() as p: with launch(viewport={'width': 1440, 'height': 860}, args=['--autoplay-policy=no-user-gesture-required']) as (b, pg, url):
b = p.chromium.launch(args=['--autoplay-policy=no-user-gesture-required'])
pg = b.new_page(viewport={'width': 1440, 'height': 860})
logs = [] logs = []
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}')) pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}'))
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
+5 -4
View File
@@ -1,5 +1,6 @@
"""Serve dist/ with isolation headers; isolated_server() owns a free-port background server.""" """Serve dist/ with isolation headers; isolated_server() owns a free-port background server."""
from contextlib import contextmanager from contextlib import contextmanager
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from threading import Thread from threading import Thread
@@ -8,8 +9,8 @@ DIST = Path(__file__).resolve().parent.parent / 'dist'
class Handler(SimpleHTTPRequestHandler): class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs): def __init__(self, *args, directory=DIST, **kwargs):
super().__init__(*args, directory=str(DIST), **kwargs) super().__init__(*args, directory=str(directory), **kwargs)
def end_headers(self): def end_headers(self):
self.send_header('Cache-Control', 'no-store') self.send_header('Cache-Control', 'no-store')
@@ -22,8 +23,8 @@ class Handler(SimpleHTTPRequestHandler):
@contextmanager @contextmanager
def isolated_server(): def isolated_server(directory=DIST):
with ThreadingHTTPServer(('127.0.0.1', 0), Handler) as server: with ThreadingHTTPServer(('127.0.0.1', 0), partial(Handler, directory=directory)) as server:
thread = Thread(target=server.serve_forever, daemon=True) thread = Thread(target=server.serve_forever, daemon=True)
thread.start() thread.start()
try: try:
+5 -10
View File
@@ -40,13 +40,9 @@ import argparse, pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from where import translate from where import translate
from use_venv import ensure_playwright; ensure_playwright() from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright from browser import launch, gpu_option
ROOT = pathlib.Path(__file__).resolve().parent.parent ROOT = pathlib.Path(__file__).resolve().parent.parent
# Headless Chromium has no GPU. These flags give it WebGL2 through SwiftShader (software), and
# window.__allowSoftwareGL tells the game to use the smooth look anyway (players on software
# rendering get the pixel look instead).
GL_ARGS = ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']
FREEZE = """(() => { const real = performance.now.bind(performance); window.__frozenNow = null; FREEZE = """(() => { const real = performance.now.bind(performance); window.__frozenNow = null;
performance.now = () => window.__frozenNow === null ? real() : window.__frozenNow; })();""" performance.now = () => window.__frozenNow === null ? real() : window.__frozenNow; })();"""
@@ -68,17 +64,16 @@ def main():
ap.add_argument('--freeze', action='store_true') ap.add_argument('--freeze', action='store_true')
ap.add_argument('--no-light', action='store_true') ap.add_argument('--no-light', action='store_true')
ap.add_argument('--page', default=str(ROOT / 'dist' / 'index.html')) ap.add_argument('--page', default=str(ROOT / 'dist' / 'index.html'))
gpu_option(ap)
a = ap.parse_args() a = ap.parse_args()
ww, wh = (int(v) for v in a.size.split('x')) ww, wh = (int(v) for v in a.size.split('x'))
vw, vh = (int(v) for v in a.viewport.split('x')) vw, vh = (int(v) for v in a.viewport.split('x'))
errors = [] errors = []
with sync_playwright() as p: with launch(gpu=a.gpu, viewport={'width': vw, 'height': vh}, page_path=a.page) as (b, pg, url):
b = p.chromium.launch(args=GL_ARGS)
pg = b.new_page(viewport={'width': vw, 'height': vh})
pg.on('pageerror', lambda e: errors.append(translate(e.stack or str(e)))) pg.on('pageerror', lambda e: errors.append(translate(e.stack or str(e))))
pg.on('console', lambda m: errors.append('console: ' + m.text) if m.type == 'error' else None) pg.on('console', lambda m: errors.append('console: ' + m.text) if m.type == 'error' else None)
pg.add_init_script('window.__allowSoftwareGL = true;' + (FREEZE if a.freeze else '')) pg.add_init_script(FREEZE if a.freeze else '')
pg.goto(pathlib.Path(a.page).resolve().as_uri()) pg.goto(url)
pg.evaluate("SET.fog = false") pg.evaluate("SET.fog = false")
pg.wait_for_timeout(300) pg.wait_for_timeout(300)
# pause the moment the world exists, so the shot depends only on the seed, the setup # pause the moment the world exists, so the shot depends only on the seed, the setup
+4 -6
View File
@@ -6,8 +6,8 @@ import math
import copy import copy
from urllib.request import urlopen from urllib.request import urlopen
from perf import ROOT, GPU_ARGS, quantiles, measurements, report from perf import ROOT, quantiles, measurements, report
from server import isolated_server from browser import launch, consume_gpu_option
from use_venv import ensure_playwright from use_venv import ensure_playwright
@@ -28,14 +28,12 @@ def main():
assert '/factory/sim/total_ms: +20.0% REGRESSION' in out.getvalue() assert '/factory/sim/total_ms: +20.0% REGRESSION' in out.getvalue()
assert 'n/a (baseline zero)' in out.getvalue() assert 'n/a (baseline zero)' in out.getvalue()
ensure_playwright() ensure_playwright()
from playwright.sync_api import sync_playwright consume_gpu_option()
with isolated_server() as url, sync_playwright() as p: with launch() as (browser, pg, url):
with urlopen(url) as response: with urlopen(url) as response:
assert response.headers['Cross-Origin-Opener-Policy'] == 'same-origin' assert response.headers['Cross-Origin-Opener-Policy'] == 'same-origin'
assert response.headers['Cross-Origin-Embedder-Policy'] == 'require-corp' assert response.headers['Cross-Origin-Embedder-Policy'] == 'require-corp'
response.read() response.read()
browser = p.chromium.launch(args=GPU_ARGS)
pg = browser.new_page(viewport={'width': 1280, 'height': 760})
errors = [] errors = []
pg.on('pageerror', lambda e: errors.append(str(e))) pg.on('pageerror', lambda e: errors.append(str(e)))
pg.add_init_script('window.requestAnimationFrame = () => 0;') pg.add_init_script('window.requestAnimationFrame = () => 0;')