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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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'],
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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}')
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}""")
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;')
|
||||||
|
|||||||
Reference in New Issue
Block a user