Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/STRATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,26 @@ text. `?bench=30` runs for 30 seconds. Nothing is sent anywhere.
CI that a run finishes and shows its card on both profiles (it keeps no
numbers: headless Chromium draws on the CPU).

### The loading shell

The page loads behind the Strata web kit's loading shell
(`strata/web/strata-kit/strata-loader.js`, vendored from Strata at the commit in
`strata/web/strata-kit/PIN`; `strata/web/strata-kit/update.sh <sha>` re-vendors it). It shows
the trailer's first frame (the one on <https://danielstephenson.dev/play>) as a blurred backdrop and real progress with the size ("0.5 of 1.3 MB"). The loader streams
the `.wasm` and hands its bytes to the engine, so nothing downloads twice. If
env-lib-cpp can't run (no WebGL 2, the renderer fails, the engine stops, a lost
WebGL context does not come back), the shell says why, and plays the trailer instead (the still under `prefers-reduced-motion`).
A device that draws under 12 frames a second is offered the clip without
losing the live view (not in automated browsers or `?bench` runs).

`strata/web/strata-boot.js` bridges this classic (non-`MODULARIZE`) build to the
loader: emcc's script tag sits inert in `<template id="strata-glue">`, the
bridge starts it, and the engine waits in `Module.instantiateWasm` for the
loader's bytes. After each link, `strata/web/strata_page.cmake` puts the loader and
the bridge inline in the page (so the site is still the same four files) and stamps the download's size on the
template, because the arcade compresses on the fly and sends no
`Content-Length`.

## Tests

- `[view]`: the copy, the no-pointer rule (an entity freed after `sync()`), an
Expand Down
10 changes: 10 additions & 0 deletions strata/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ else()
-sEXPORTED_RUNTIME_METHODS=ccall,cwrap
"--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/web/shell.html")
set_property(TARGET envlib_strata_web APPEND PROPERTY LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/web/shell.html")
# The loading shell (web/strata-kit, vendored from Strata) and its bridge go
# inline into the page, with the download's size (web/strata_page.cmake).
set_property(TARGET envlib_strata_web APPEND PROPERTY LINK_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/web/strata-kit/strata-loader.js"
"${CMAKE_CURRENT_SOURCE_DIR}/web/strata-boot.js")
add_custom_command(TARGET envlib_strata_web POST_BUILD
COMMAND "${CMAKE_COMMAND}" "-DHTML=$<TARGET_FILE:envlib_strata_web>"
"-DWASM=$<TARGET_FILE_DIR:envlib_strata_web>/$<TARGET_FILE_BASE_NAME:envlib_strata_web>.wasm"
"-DWEB=${CMAKE_CURRENT_SOURCE_DIR}/web" -P "${CMAKE_CURRENT_SOURCE_DIR}/web/strata_page.cmake"
VERBATIM)
# The page's ?bench mode (web/bench.js) is served next to it.
configure_file(web/bench.js "${CMAKE_CURRENT_BINARY_DIR}/bench.js" COPYONLY)
endif()
Expand Down
29 changes: 27 additions & 2 deletions strata/web/shell.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
:root {
--bg: #090b12; --panel: rgba(16, 19, 30, 0.94); --line: #2a3046; --text: #e6eaf2; --dim: #9aa3b8;
--accent: #ffc23a; --btn: #1a1f30; --btn-on: #3a3118;
/* The loading shell (web/strata-kit/strata-loader.js) in the page's colours. */
--sk-loader-bg: var(--bg); --sk-loader-fg: var(--text); --sk-loader-panel: var(--panel); --sk-loader-accent: var(--accent);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
Expand Down Expand Up @@ -79,7 +81,7 @@
<button id="hud" data-key="43" aria-pressed="true" title="Show or hide the text on the view (Tab)">HUD</button>
</div>

<div id="loading">Loading env-lib-cpp on Strata&hellip;</div>
<div id="loading" style="display: none"></div>

<!-- ?bench: a scripted frame-rate run (web/bench.js); it must load before the engine. -->
<script src="bench.js"></script>
Expand Down Expand Up @@ -149,6 +151,7 @@
printErr: (t) => console.warn(t),
onRendererFailed: (why) => {
loading.textContent = 'This browser could not start WebGL 2 (' + why + ').';
StrataLoader.fallback('engine', 'This browser could not start WebGL 2 (' + why + ').');
},
onViewerReady: () => {
api = {
Expand All @@ -163,6 +166,7 @@
};
window.envlib = api;
loading.style.display = 'none';
StrataBoot.ready();
fit();
let start = 0;
try { start = Number(localStorage.getItem('envlib.world')) || 0; } catch (e) {}
Expand Down Expand Up @@ -213,6 +217,27 @@
window.addEventListener('resize', fit);
new ResizeObserver(fit).observe(document.getElementById('stage'));
</script>
{{{ SCRIPT }}}
<!-- emcc's script tag, kept inert: web/strata-boot.js starts it behind the
loading shell. data-bytes is the download's size, stamped by the build. -->
<template id="strata-glue" data-bytes="0">{{{ SCRIPT }}}</template>
<!-- The loading shell, vendored from Strata, and its bridge to this build; the
build puts both inline (web/strata_page.cmake). -->
<script src="strata-kit/strata-loader.js"></script>
<script src="strata-boot.js"></script>
<script>
// The loading shell: the trailer's first frame behind real progress, and the
// trailer itself, with the reason, if the live view can't run here. The
// media are the /play trailer.
StrataBoot.start({
wasm: 'envlib-strata.wasm',
title: 'env-lib-cpp',
poster: 'https://danielstephenson.dev/trailers/env-lib-cpp.jpg',
posterAlt: 'env-lib-cpp: a grid world and its entities',
trailer: { webm: 'https://danielstephenson.dev/trailers/env-lib-cpp.webm', mp4: 'https://danielstephenson.dev/trailers/env-lib-cpp.mp4' },
trailerLabel: 'A recorded clip of env-lib-cpp',
fallback: { href: 'https://danielstephenson.dev/play', text: 'More games' },
canvas: document.getElementById('canvas')
}).catch(function () {}); // the loading shell already shows why
</script>
</body>
</html>
31 changes: 31 additions & 0 deletions strata/web/smoke/smoke.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,37 @@ const worlds = [
if (s.pickX < 0 || direct.pickX < 0 || Math.abs(s.pickX - direct.pickX) > 1 || Math.abs(s.pickY - direct.pickY) > 1)
await fail(`a ${phone ? 'tap' : 'click'} picked (${s.pickX}, ${s.pickY}), but that point is (${direct.pickX}, ${direct.pickY})`);

// The loading shell finished; and without WebGL 2 it shows the trailer (or
// its still) and says why, and the engine never downloads.
{
await page.waitForFunction(() => { const r = document.querySelector('.sk-loader'); return r && r.hidden; }, null, { timeout: 10000 }).catch(() => {});
const shell = await page.evaluate(() => {
const r = document.querySelector('.sk-loader');
return { hidden: !!(r && r.hidden), state: window.StrataLoader && StrataLoader.state };
});
if (!shell.hidden || shell.state !== 'done') await fail('the loading shell did not finish: ' + JSON.stringify(shell));
console.log('ok: the loading shell showed and is gone');
const bare = await browser.newContext({ ...base, userAgent: (base.userAgent || 'Mozilla/5.0') + ' envlib-smoke headless' });
await bare.addInitScript(() => {
const get = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (t, o) { return t === 'webgl2' ? null : get.call(this, t, o); };
});
const p2 = await bare.newPage();
const engine = [];
p2.on('request', (r) => { if (/envlib-strata\.(wasm|js)$/.test(r.url())) engine.push(r.url()); });
await p2.goto(url, { waitUntil: 'commit' });
await p2.waitForFunction(() => window.StrataLoader && StrataLoader.state === 'fallback', null, { timeout: 30000 });
const fb = await p2.evaluate(() => {
const r = document.querySelector('.sk-loader');
return { text: r.innerText, media: !!r.querySelector('video, img'), wide: document.documentElement.scrollWidth > innerWidth };
});
await p2.screenshot({ path: path.join(outDir, 'no-webgl2.png') });
if (!/WebGL 2/.test(fb.text) || fb.wide) await fail('without WebGL 2 the shell did not say why: ' + JSON.stringify(fb));
if (engine.length) await fail('without WebGL 2 the engine still downloaded: ' + engine.join(', '));
console.log('ok: without WebGL 2 the shell ' + (fb.media ? 'shows the trailer and ' : '') + 'says why, and nothing of the engine downloads');
await bare.close();
}

if (errors.length) await fail('page errors: ' + errors.join(' | '));
await page.screenshot({ path: path.join(outDir, 'page.png') });
await browser.close();
Expand Down
180 changes: 180 additions & 0 deletions strata/web/strata-boot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/* strata-boot.js: starts this page's Emscripten build behind the Strata web
kit's loading shell (strata-kit/strata-loader.js, vendored at a pinned
Strata commit).

The loader expects a MODULARIZE build (a factory it calls with wasmBinary).
This build is a classic one: a global Module and the glue script that
emcc writes for {{{ SCRIPT }}}, which the shell keeps inside
<template id="strata-glue"> so that it does not run by itself. This file
bridges the two:

- the glue starts at once, as before, so a --preload-file data package
downloads alongside the engine; it waits in Module.instantiateWasm;
- the loader fetches the .wasm as a stream, shows its bytes, and hands
them to the factory below, which instantiates them: nothing downloads
twice;
- an abort, a lost WebGL context that does not come back, and (outside
automated browsers and ?bench) a device drawing under 12 frames a
second all end in the loader's fallback: the trailer or the poster,
with the reason.

StrataBoot.start({
wasm: "index.wasm", title: "...", // the loader's options, plus:
canvas: document.getElementById("canvas"),
});
// when the first frame is up (the page's own "ready"):
StrataBoot.ready();

`bytes` defaults to data-bytes on the template, which the build stamps
(web/strata_page.cmake): the .wasm plus any data package, since the arcade
compresses on the fly and sends no Content-Length.

MIT licensed, as this repository. */
(function () {
"use strict";

const FACTORY = "__strataBootFactory";
let opts = {}, running = false, settled = false, failNow = null;
let wasmLen = null, data = null;

// A data package downloads alongside the .wasm. The loader counts the .wasm;
// once it has, the bar goes on with the package's bytes on top, against the
// page's stated size (both together). This writes into the loader's own
// overlay (.sk-bar, .sk-fill, .sk-size, as strata-loader.js at PIN builds it).
function mb(n) { return (n / 1048576).toFixed(n < 10485760 ? 1 : 0); }
function showData() {
if (wasmLen == null || !data || StrataLoader.state !== "loading") return;
const bar = document.querySelector(".sk-loader .sk-bar");
if (!bar) return;
const stated = opts.bytes || 0;
const sane = data.got <= data.total; // a compressed Content-Length is smaller than the bytes read
const total = sane ? Math.max(stated, wasmLen + data.total) : 0;
const got = wasmLen + data.got;
const size = document.querySelector(".sk-loader .sk-size");
if (total) {
const pct = Math.min(100, Math.round(got / total * 100));
bar.classList.remove("sk-indeterminate");
bar.querySelector(".sk-fill").style.width = pct + "%";
bar.setAttribute("aria-valuenow", String(pct));
if (size) size.textContent = `${mb(Math.min(got, total))} of ${mb(total)} MB`;
} else if (size) {
size.textContent = `${mb(got)} MB`;
}
}

// The glue's src, from the inert copy of emcc's script tag.
function glueSrc() {
const t = document.getElementById("strata-glue");
const s = t && t.content && t.content.querySelector("script[src]");
return s ? s.getAttribute("src") : null;
}

function engineFailed(message) {
if (settled) return;
if (failNow) { failNow(new Error(message)); return; } // still loading: the loader shows it
settled = true;
StrataLoader.fallback("engine", message ? `The live view's renderer stopped. (${message})` : undefined);
}

// Links on the fallback leave a framed page (the arcade embeds games) as a whole.
function topLinks() {
document.querySelectorAll(".sk-loader a[href]").forEach((a) => { a.target = "_top"; a.rel = "noopener"; });
}

function watchContext(canvas) {
if (!canvas) return;
let timer = null;
canvas.addEventListener("webglcontextlost", () => {
clearTimeout(timer);
timer = setTimeout(() => { if (!settled) { settled = true; StrataLoader.fallback("context-lost"); } }, 3000);
});
canvas.addEventListener("webglcontextrestored", () => clearTimeout(timer));
}

// Under 12 frames a second over 8 s of visible time, 3 s after the first
// frame: offer the clip without taking the live view away. Automated
// browsers (software GL) and ?bench runs are left alone.
function watchSpeed() {
if (navigator.webdriver || /[?&]bench\b/.test(location.search)) return;
let frames = 0, visibleMs = 0, last = 0;
document.addEventListener("visibilitychange", () => { last = 0; });
function step(now) {
if (settled) return;
if (last && !document.hidden) { const dt = now - last; if (dt < 1000) { visibleMs += dt; frames++; } }
last = now;
if (visibleMs < 8000) { requestAnimationFrame(step); return; }
if (frames * 1000 / visibleMs < 12) StrataLoader.slow();
}
setTimeout(() => requestAnimationFrame(step), 3000);
}

function start(o) {
opts = Object.assign({}, o);
const src = glueSrc();
if (!src) throw new Error("strata-boot: put <template id=\"strata-glue\"> with emcc's script tag before this call");
const M = window.Module = window.Module || {};
const userAbort = M.onAbort;
M.onAbort = (what) => { if (userAbort) userAbort(what); engineFailed(String(what || "aborted")); };
const userFallback = opts.onFallback;
opts.onFallback = (code) => { settled = true; topLinks(); if (userFallback) userFallback(code); };

// The glue asks for its instance here; the factory below answers once
// the loader has the bytes. The factory settles with the instantiation.
let give;
const instance = new Promise((r) => { give = r; });
M.instantiateWasm = (imports, ok) => {
instance.then((b) => WebAssembly.instantiate(b, imports))
.then((r) => { failNow = null; ok(r.instance, r.module); if (resolveFactory) resolveFactory(M); },
(e) => engineFailed(String(e && e.message || e)));
return {};
};
// A --preload-file package reports through setStatus("... (got/total)").
const userStatus = M.setStatus;
M.setStatus = (text) => {
if (userStatus) userStatus(text);
const m = /\((\d+)\/(\d+)\)/.exec(text || "");
if (m) { data = {got: +m[1], total: +m[2]}; showData(); }
};

let resolveFactory = null;
window[FACTORY] = (args) => new Promise((resolve, reject) => {
failNow = reject;
resolveFactory = resolve;
wasmLen = args.wasmBinary.byteLength;
showData();
give(args.wasmBinary);
});

// No WebGL 2: the loader shows the fallback before anything downloads.
if (StrataLoader.hasWebGL2()) {
const s = document.createElement("script");
s.src = src;
s.async = true;
s.onerror = () => {
if (settled) return;
settled = true;
StrataLoader.fallback("network", `The live view didn't download. (${src})`);
};
document.body.appendChild(s);
}
running = true;
watchContext(opts.canvas);
if (!opts.bytes) {
const t = document.getElementById("strata-glue");
const n = t ? parseInt(t.getAttribute("data-bytes") || "", 10) : 0;
if (n > 0) opts.bytes = n;
}
const loadOpts = Object.assign({}, opts, {script: src, factory: FACTORY});
delete loadOpts.canvas;
return StrataLoader.load(loadOpts).then((mod) => mod, (e) => { settled = true; throw e; });
}

// The first frame is up: fade the shell out and start watching the speed.
function ready() {
if (!running || settled) return;
StrataLoader.done();
watchSpeed();
}

window.StrataBoot = {start, ready};
})();
21 changes: 21 additions & 0 deletions strata/web/strata-kit/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Daniel McCoy Stephenson

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1 change: 1 addition & 0 deletions strata/web/strata-kit/PIN
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
901e6c97bc70c86c0e7dfbf857bb87ec405e0164
2 changes: 2 additions & 0 deletions strata/web/strata-kit/SHA256SUMS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
7da2a43f932f63e5ae86273a7675b55180dbde4d61dc6e0c5fcd048026879946 strata-loader.js
8e3fc647b4775773eabc147e564ffbddd0653b252eff96c5432213ee362798df LICENSE
Loading
Loading