1
0

feat: the boards stop being three products

The owner asked twice why there are still three separate boards. The honest
answer, and what this round executes: **it feels like three boards, but not
because the scale jumps 20x — because the three packs draw three different
Californias, and the loudest difference is that the mountains are four times
taller on one of them.**

**THE 20x HORIZONTAL SCALE JUMP IS INVISIBLE**, and measuring that collapsed the
cost of this whole round. `World.project` is a uniform scale in x/z with no
vertical term, and a uniform scale leaves a perspective image identical — so a
camera carried across the seam on matched true-metre offsets draws a
pixel-identical horizontal frame. 1,919 -> 94 m/unit costs nothing to look at.
Rescaling was never the problem. A boot card, a tab strip and a 4.17x vertical
deflation were.

**THE PAUSE WAS MOSTLY FAKE.** A switch covered the screen for 1,715 ms but only
608 ms blocked the main thread; the page drew 46 of 69 frames with nothing to
show, because the outgoing board had already been disposed. `mountCity` now
retains it: the incoming board builds BEHIND a live, interactive picture, and
`stage.setScene` fires only on completion. Measured across all six directions,
three runs each — boot card yes -> **no**, opaque cover 726-1,415 ms -> **0**,
blank frames 21-46 -> **exactly 1**, wall clock down 12-29%, blocked main thread
down 15-47%. A return to a board already seen links **zero** shader programs and
blocks **zero** milliseconds: 298-312 ms of camera flight where it was ~1,600 ms
behind a card. Disposal had been throwing away the shader cache too — linkProgram
ran 38, 59, 78, 109, 127 across five mounts and never reused one.

**The transition is a fog dip, not a crossfade**, through the `setAerialFog` seam
built last round. Every both-boards-live crossfade breaks a budget — ca+sf is
2,640,307 triangles against bay-area's 2,600,000 cap — and a fade never lands
inside the harness's sample window, which is the "a cap you do not measure is a
cap you do not have" failure this repo already argues against. The dip costs zero
triangles and zero draw calls, and it hides the 4.17x deflation, the 4,025 m
projection disagreement and the vanishing 2 km freeway symbols at once, because
all three happen at maximum obscuration. It is also diegetic: a descent through
haze.

The first dip was wrong and the photograph caught it: collapsing to 6% of board
SPAN turned the whole night frame into one flat field — the exact "turns the map
off" failure the risk list named. Re-anchored to 70% of camera STAND-OFF, so the
coastline survives and only the relief melts.

**One ladder, one places list.** 26 authored chapters become 24 rungs sorted
descending by STAND-OFF, not altitude — by altitude they interleave badly and
altitude cannot tell a low oblique from a high plan. The three-board tab strip is
off by default; the left column is now one scrolling list of all 24 rungs under
three region headings that does not change when the board does. Only which row is
lit changes. Label collisions are resolved in the ladder and never in a pack, so
the 29 index-aimed capture guards are untouched.

The minimap stops turning through 90 degrees between boards: every board is
pinned to a rectangle with California's proportions.

**SF and SoCal are not regressed**, and that was the acceptance that mattered:
95.9-98.8% of board pixels are delta-0 against a baseline hash-verified identical
to what the live site serves, and every one of the 34-70 surviving pixels per
frame is an aircraft or a hull.

**A real defect found only by photograph:** `minimap.setMarkers()` had zero call
sites. Every marker on every board was gone — the LA studio's door dot, the Bay
Area's eight company markers — dropped when the minimap went per-board.
Typecheck, tests, budgets and the console were all green with that bug in.

Also fixed: two capture presets that lied. `look.mjs`'s `glyph-la` and `glyph-sf`
claimed California chapter closeups and returned SoCal and Bay Area frames,
because they aimed by chapter index and the indices had moved. Aiming is now by
identity, with a guard test.

NOT SHIPPED, DELIBERATELY: the pack merge. At Bay density it is 34.04M triangles,
13x the highest budget — dead, not a trade. At SoCal density it is 1.99M and fits
today, and the price is San Francisco rendering at 164 m lots instead of 40 m,
i.e. SF looking the way SoCal looks now. SF and SoCal carry every marketing still
on the site. That is the owner's decision and it is worthless as an argument and
decisive as a photograph, so it ships as a measurement artifact with a
side-by-side still and is wired into nothing. The four data reconciliations that
would make one world honest — one exaggeration rule, roads in metres, one
projection centre, one coastline convention — are behind TERA_RECONCILE, default
OFF.

Tests 1,570 -> 1,651, server 295. All ten budget cells pass, no cap raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 06:20:18 -07:00
parent bcac6aa41a
commit 4bd8481be1
32 changed files with 6628 additions and 264 deletions
+471
View File
@@ -0,0 +1,471 @@
#!/usr/bin/env node
/**
* The chapter list is a public interface. This is the check that says so.
*
* node scripts/check-chapter-identity.mjs # against dist/
* node scripts/check-chapter-identity.mjs --dist … # against another build
* node scripts/check-chapter-identity.mjs --write # re-cut the fixture (read the warning)
*
* ## What it protects, and why it had to exist before the ladder work
*
* Twenty-six index-aimed guards outside this file point a camera at a chapter by
* its **position in a list** and then assert its short label. Counted from the
* source rather than taken from a plan — comment lines that *quote* the pattern
* are excluded, which is where the "29" in the round's brief came from:
*
* - `scripts/brand-assets/shots.mjs` — 21, every one of them a still on
* lumbridgecorp.com right now.
* - `scripts/brand-assets/films.mjs` — 4.
* - `scripts/brand-assets/capture.mjs` — 1: the Open Graph card, which is what
* every link to the site renders as, and the most-seen frame in the product.
*
* Twenty-one of them aim at a board a `?city=` can reach — 12 at the Bay Area,
* 6 at California, 3 at SoCal — and this check covers those. The other five are
* office shots aiming at a studio's own view list, which no `?city=` selects.
*
* That pattern was chosen for a good reason and it is written up at
* `capture.mjs:70`: a card once shipped for a fortnight showing a chase camera
* on a freeway under the headline "Cities from above", because `keyboard.press("2")`
* is an unguarded index into pack data and the default board had changed under it.
* The label assertion was the fix. It is a *good* fix and it is not sufficient,
* for two reasons this repo can now demonstrate rather than argue:
*
* 1. **Labels are not unique across boards.** "Whole Board" is chapter 01 of the
* Bay Area *and* chapter 01 of SoCal; "The Valley" is Silicon Valley on one
* board and the San Fernando Valley on the other. A reorder that preserves
* labels passes every one of those twenty-six guards and shoots the wrong place.
* 2. **A guard on the chapter you clicked says nothing about the one you did
* not.** Several shots take no chapter at all and inherit whatever board the
* URL opened on, which is a fallback rather than an assertion.
*
* So this file snapshots the *identity* of every chapter on every board — its
* `data-view`, its position, its printed number and its short label — against a
* checked-in fixture, and fails on any drift. It is deliberately not a test of
* whether the chapters are *good*; it is a test of whether they are the same
* ones the marketing imagery was aimed at.
*
* ## It reads the DOM, not the packs
*
* A unit test over `src/cities/*.ts` would be cheaper and would miss the thing
* that actually breaks: the harnesses read `#chapters .chapter`, and what lands
* there is `chromeState.ts`'s `views` array after `main.ts` has decided which
* board is up. Number is taken from the pack *or* the ordinal, and the
* `data-view-index` written into the button is the index into that rendered
* list. Only the DOM knows all of it at once. `src/test/chapterIdentity.test.ts`
* carries the fast pack-level half of the same contract, including the negative
* case, so that a reorder fails in `npm test` in a second rather than here in a
* minute.
*
* ## --write
*
* Regenerating the fixture is how you record a deliberate change, and it is not
* a way to make this go green. Every drift it reports is a frame on
* lumbridgecorp.com that is about to become a picture of somewhere else, and the
* 26 aims above have to be re-pointed in the same commit — which means
* re-shooting the imagery, which is an owner decision, not a refactor.
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const DEFAULT_FIXTURE = join(ROOT, "scripts", "fixtures", "chapter-identity.json");
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".webmanifest": "application/manifest+json",
};
const args = process.argv.slice(2);
const option = (name, fallback) => {
const at = args.indexOf(`--${name}`);
return at < 0 ? fallback : args[at + 1];
};
const has = (name) => args.includes(`--${name}`);
const DIST = resolve(option("dist", join(ROOT, "dist")));
const WRITE = has("write");
const READY_TIMEOUT_MS = Number(option("timeout-ms", "90000")) || 90_000;
/*
* `--fixture` exists so the failure can be watched.
*
* A guard nobody has seen fail is not a guard, and the only other way to see
* this one fail is to break the packs — on a shared tree, with two other
* workstreams in the same files. Point it at a deliberately scrambled copy
* instead:
*
* jq '.boards.sf.chapters |= (.[0:2] + [.[3], .[2]] + .[4:])' \
* scripts/fixtures/chapter-identity.json > /tmp/scrambled.json
* node scripts/check-chapter-identity.mjs --fixture /tmp/scrambled.json # must FAIL
*/
const FIXTURE = resolve(option("fixture", DEFAULT_FIXTURE));
/**
* The boards, by the `?city=` value that selects them.
*
* This list is itself part of the contract: `?city=` is the coordinate every
* capture harness and every deep link uses, and `main.ts` falls back to the
* first board rather than failing on an unknown id — so a board that quietly
* lost its id would not throw, it would silently serve California to every shot
* aimed at SoCal. The snapshot records which board answered, so that failure
* shows up as a mismatch instead of as a fortnight of wrong pictures.
*/
const CITY_PARAMS = ["california", "sf", "socal"];
async function serve() {
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://local.invalid");
const json = (status, body) => {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body));
};
if (url.pathname === "/api/v1/health") {
return json(200, {
auth: { mode: "jwt", entryUrl: "/login.html" },
sources: { weather: "none", flights: "none", satellites: "none", markers: "none" },
regions: [],
});
}
if (url.pathname === "/api/v1/session") {
return json(200, { authenticated: false, subject: null, passwordLogin: true, admin: false });
}
if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" });
const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
const target = resolve(DIST, `.${relative}`);
if (!target.startsWith(`${resolve(DIST)}/`)) continue;
try {
const body = await readFile(target);
res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
res.end(body);
return;
} catch {
/* fall through to the SPA entry */
}
}
res.writeHead(404).end("not found");
});
await new Promise((ok, fail) => {
server.once("error", fail);
server.listen(0, "127.0.0.1", ok);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("no TCP port");
return { server, port: address.port };
}
async function launch() {
const resolver = "--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1";
const common = ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist"];
const ladder = [
["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]],
["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]],
];
let last;
for (const [backend, flags] of ladder) {
try {
const browser = await chromium.launch({ channel: "chrome", args: [...common, resolver, ...flags] });
return { browser, backend };
} catch (error) {
last = error;
}
}
throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}
/** Everything the DOM knows about the chapter strip on one board. */
async function snapshotBoard(browser, port, cityParam) {
const page = await browser.newPage();
try {
await page.goto(`http://tera.lumbridgecorp.com:${port}/?city=${cityParam}`, {
waitUntil: "networkidle",
timeout: READY_TIMEOUT_MS,
});
await page.waitForFunction(
() =>
document.getElementById("boot")?.hidden === true &&
document.querySelectorAll("#chapters .chapter").length > 0,
null,
{ timeout: READY_TIMEOUT_MS },
);
return await page.evaluate(() => {
const text = (node) => (node?.textContent ?? "").trim();
const chapters = [...document.querySelectorAll("#chapters .chapter")].map((button, index) => {
const spans = [...button.querySelectorAll("span")];
return {
index,
dataView: button.getAttribute("data-view"),
dataViewIndex: button.getAttribute("data-view-index"),
number: text(spans[0]),
shortLabel: text(spans[spans.length - 1]),
};
});
const boards = [...document.querySelectorAll("[data-board]")].map((button) => ({
id: button.getAttribute("data-board"),
label: text(button.querySelector(".board__name")) || text(button),
active: button.getAttribute("aria-pressed") === "true",
}));
return { chapters, boards, activeBoard: boards.find((b) => b.active)?.id ?? null };
});
} finally {
await page.close();
}
}
// ---- The aims this exists to protect --------------------------------------
//
// Read out of the capture harnesses rather than duplicated here, because a copy
// would rot in exactly the way this file is trying to stop. Nothing under
// `scripts/brand-assets/` is written to — this reads their source and reports
// which of their `chapter: N` / `expect: "…"` pairs no longer agree with the
// board they aim at.
async function readAims() {
const files = [
["scripts/brand-assets/shots.mjs", "shot"],
["scripts/brand-assets/films.mjs", "film"],
["scripts/brand-assets/capture.mjs", "og-card"],
];
const aims = [];
for (const [relative, kind] of files) {
let source;
try {
source = await readFile(join(ROOT, relative), "utf8");
} catch {
continue;
}
const lines = source.split("\n");
let id = null;
let door = null;
let city = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (/^\s*\*/.test(line)) continue; // a comment quoting one of these is not one
const idAt = /^\s*id:\s*"([^"]+)"/.exec(line);
if (idAt) {
id = idAt[1];
door = null;
city = null;
}
const doorAt = /^\s*door:\s*"([^"]+)"/.exec(line);
if (doorAt) door = doorAt[1];
const cityAt = /^\s*city:\s*"([^"]+)"/.exec(line);
if (cityAt) city = cityAt[1];
// `capture.mjs` does not carry a shot record — it calls `shootApp` with a
// URL. The Open Graph card is the single most-seen frame in the product
// (it is what every link to lumbridgecorp.com renders as), so its aim is
// read out of the URL rather than left uncovered.
const urlAt = /"https?:\/\/[^"]*[?&]city=([a-z-]+)[^"]*"/.exec(line);
if (urlAt) {
city = urlAt[1];
door = null;
const nameAt = /"([\w-]+\.png)"/.exec(line);
if (nameAt) id = nameAt[1];
}
const chapterAt = /^\s*chapter:\s*(\d+)/.exec(line);
if (!chapterAt) continue;
let expect = null;
for (let j = i + 1; j < Math.min(lines.length, i + 6); j += 1) {
const expectAt = /^\s*expect:\s*"([^"]+)"/.exec(lines[j]);
if (expectAt) {
expect = expectAt[1];
break;
}
}
aims.push({
file: relative,
line: i + 1,
kind,
id: id ?? "(unnamed)",
// An office shot has no `city:` — it aims at the studio's own view list,
// which this check does not cover and which no `?city=` can reach.
city: door === "office" ? null : city,
chapter: Number(chapterAt[1]),
expect,
});
}
}
return aims;
}
// ---- Run ------------------------------------------------------------------
const { server, port } = await serve();
const { browser, backend } = await launch();
let observed;
try {
observed = {};
for (const cityParam of CITY_PARAMS) {
observed[cityParam] = await snapshotBoard(browser, port, cityParam);
}
} finally {
await browser.close();
server.close();
}
const snapshot = {
takenAt: "bcac6aa",
note:
"The chapter strip as the capture harnesses see it. Regenerate with --write ONLY as " +
"part of a deliberate, owner-approved re-shoot: 26 index-aimed guards in " +
"scripts/brand-assets/** point at these positions, 21 of them at a board a ?city= " +
"reaches, and every one of them is a frame on lumbridgecorp.com.",
boards: Object.fromEntries(
CITY_PARAMS.map((cityParam) => [
cityParam,
{
activeBoard: observed[cityParam].activeBoard,
boardTabs: observed[cityParam].boards.map((board) => ({ id: board.id, label: board.label })),
chapters: observed[cityParam].chapters,
},
]),
),
};
if (WRITE) {
await writeFile(FIXTURE, `${JSON.stringify(snapshot, null, 2)}\n`);
console.log(`check-chapter-identity: wrote ${FIXTURE}`);
console.log(
"check-chapter-identity: a re-cut fixture means the 26 aims in scripts/brand-assets/**\n" +
" are now pointed at whatever this build happens to render. Re-point them and re-shoot.",
);
process.exit(0);
}
let expected;
try {
expected = JSON.parse(await readFile(FIXTURE, "utf8"));
} catch (error) {
console.error(`check-chapter-identity: cannot read ${FIXTURE}${error}`);
console.error(" Take one with --write, from a build you have looked at.");
process.exit(1);
}
/*
* TWO SEVERITIES, AND THE LINE BETWEEN THEM IS "DOES A CAMERA AIM AT IT".
*
* `failures` is the chapter strip: `data-view`, position, printed number, short
* label, and how many there are. Every one of those is something a capture
* harness resolves a frame through, so drift there is a picture of somewhere
* else and stops the run.
*
* `notes` is the board chrome — the tab strip and which tab is pressed. That was
* a hard failure in the first version of this file and it fired within the hour,
* on the continuity work legitimately removing the three-tab strip. Nothing aims
* a camera at a tab: `?city=` selects the board and the chapter list is what
* proves which board answered, because the ids are unique per pack. So chrome
* drift is printed and does not fail.
*
* The silent-fallback case is still caught, and caught harder: `?city=sf`
* degrading to California renders California's six chapters, which is a
* count-and-id failure on every row.
*/
const failures = [];
const notes = [];
const field = (where, want, got, soft = false) => {
if (want === got) return;
(soft ? notes : failures).push(
`${where}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`,
);
};
for (const cityParam of CITY_PARAMS) {
const want = expected.boards?.[cityParam];
const got = snapshot.boards[cityParam];
if (!want) {
failures.push(`?city=${cityParam}: not in the fixture — a board appeared`);
continue;
}
field(`?city=${cityParam} pressed tab`, want.activeBoard, got.activeBoard, true);
field(
`?city=${cityParam} board tabs`,
want.boardTabs.map((tab) => `${tab.id}:${tab.label}`).join(" | "),
got.boardTabs.map((tab) => `${tab.id}:${tab.label}`).join(" | "),
true,
);
field(`?city=${cityParam} chapter count`, want.chapters.length, got.chapters.length);
const count = Math.min(want.chapters.length, got.chapters.length);
for (let i = 0; i < count; i += 1) {
for (const key of ["dataView", "dataViewIndex", "number", "shortLabel", "index"]) {
field(`?city=${cityParam} chapter[${i}].${key}`, want.chapters[i][key], got.chapters[i][key]);
}
}
}
for (const cityParam of Object.keys(expected.boards ?? {})) {
if (!CITY_PARAMS.includes(cityParam)) failures.push(`?city=${cityParam}: gone from this build`);
}
// Whether or not the fixture drifted, say which aims are currently right — a
// green fixture with a misaimed shot is exactly the state this repo was in at
// bcac6aa, and it was found with a photograph rather than with a check.
const aims = await readAims();
const misaimed = [];
for (const aim of aims) {
if (aim.city === null) continue;
const board = snapshot.boards[aim.city];
if (!board) {
misaimed.push(`${aim.file}:${aim.line} ${aim.id} — city:"${aim.city}" is not a board`);
continue;
}
const chapter = board.chapters[aim.chapter];
if (!chapter) {
misaimed.push(
`${aim.file}:${aim.line} ${aim.id} — chapter ${aim.chapter} does not exist on ${aim.city}`,
);
continue;
}
if (aim.expect !== null && chapter.shortLabel !== aim.expect) {
misaimed.push(
`${aim.file}:${aim.line} ${aim.id} — chapter ${aim.chapter} on ${aim.city} is ` +
`"${chapter.shortLabel}" (${chapter.dataView}), the aim says "${aim.expect}"`,
);
}
}
const covered = aims.filter((aim) => aim.city !== null).length;
console.log(`check-chapter-identity — dist ${DIST} (${backend})`);
for (const cityParam of CITY_PARAMS) {
const board = snapshot.boards[cityParam];
console.log(
` ?city=${cityParam.padEnd(11)} ${String(board.chapters.length).padStart(2)} chapters ` +
board.chapters.map((chapter) => chapter.dataView).join(", "),
);
}
console.log(
` ${aims.length} index-aimed guards read from the capture harnesses; ` +
`${covered} of them aim at a board this check covers.`,
);
if (notes.length > 0) {
console.log("");
console.log("NOTE — the board chrome moved. Nothing aims a camera at it, so this is not a");
console.log("failure; re-cut the fixture with --write when the change is settled:");
for (const line of notes) console.log(` ${line}`);
}
if (misaimed.length > 0) {
console.log("");
console.log("MISAIMED — these guards would shoot a different frame than their comment claims:");
for (const line of misaimed) console.log(` ${line}`);
}
if (failures.length > 0) {
console.log("");
console.log(`FAIL — the chapter strip moved under ${failures.length} field(s):`);
for (const line of failures) console.log(` ${line}`);
console.log("");
console.log(
"Every one of these is a capture aim that now points somewhere else. Re-point the\n" +
"guards in scripts/brand-assets/**, re-shoot the imagery, and only then --write.",
);
}
const bad = failures.length + misaimed.length;
if (bad === 0) console.log("check-chapter-identity: OK");
process.exit(bad === 0 ? 0 : 1);
+245
View File
@@ -0,0 +1,245 @@
{
"takenAt": "bcac6aa",
"note": "The chapter strip as the capture harnesses see it. Regenerate with --write ONLY as part of a deliberate, owner-approved re-shoot: 30 index-aimed guards in scripts/brand-assets/** point at these positions and every one of them is a frame on lumbridgecorp.com.",
"boards": {
"california": {
"activeBoard": "california",
"boardTabs": [
{
"id": "california",
"label": "California"
},
{
"id": "sf",
"label": "Bay Area"
},
{
"id": "socal",
"label": "SoCal"
}
],
"chapters": [
{
"index": 0,
"dataView": "california-overview",
"dataViewIndex": "0",
"number": "01",
"shortLabel": "State"
},
{
"index": 1,
"dataView": "la-sf-us-101",
"dataViewIndex": "1",
"number": "02",
"shortLabel": "101"
},
{
"index": 2,
"dataView": "la-sf-i-5",
"dataViewIndex": "2",
"number": "03",
"shortLabel": "I-5"
},
{
"index": 3,
"dataView": "los-angeles",
"dataViewIndex": "3",
"number": "04",
"shortLabel": "LA"
},
{
"index": 4,
"dataView": "san-francisco",
"dataViewIndex": "4",
"number": "05",
"shortLabel": "SF"
},
{
"index": 5,
"dataView": "shasta-cascades",
"dataViewIndex": "5",
"number": "06",
"shortLabel": "North"
}
]
},
"sf": {
"activeBoard": "sf",
"boardTabs": [
{
"id": "california",
"label": "California"
},
{
"id": "sf",
"label": "Bay Area"
},
{
"id": "socal",
"label": "SoCal"
}
],
"chapters": [
{
"index": 0,
"dataView": "all",
"dataViewIndex": "0",
"number": "01",
"shortLabel": "Whole Board"
},
{
"index": 1,
"dataView": "hayes-valley",
"dataViewIndex": "1",
"number": "02",
"shortLabel": "Hayes Valley"
},
{
"index": 2,
"dataView": "soma",
"dataViewIndex": "2",
"number": "03",
"shortLabel": "SoMa"
},
{
"index": 3,
"dataView": "fidi",
"dataViewIndex": "3",
"number": "04",
"shortLabel": "FiDi"
},
{
"index": 4,
"dataView": "mission-bay",
"dataViewIndex": "4",
"number": "05",
"shortLabel": "Mission Bay"
},
{
"index": 5,
"dataView": "mission",
"dataViewIndex": "5",
"number": "06",
"shortLabel": "Mission"
},
{
"index": 6,
"dataView": "peninsula",
"dataViewIndex": "6",
"number": "07",
"shortLabel": "Peninsula"
},
{
"index": 7,
"dataView": "silicon-valley",
"dataViewIndex": "7",
"number": "08",
"shortLabel": "The Valley"
},
{
"index": 8,
"dataView": "south-bay",
"dataViewIndex": "8",
"number": "09",
"shortLabel": "San Jose"
},
{
"index": 9,
"dataView": "east-bay",
"dataViewIndex": "9",
"number": "10",
"shortLabel": "East Bay"
},
{
"index": 10,
"dataView": "north-bay",
"dataViewIndex": "10",
"number": "11",
"shortLabel": "Marin"
},
{
"index": 11,
"dataView": "bay-area",
"dataViewIndex": "11",
"number": "12",
"shortLabel": "The Bay"
}
]
},
"socal": {
"activeBoard": "socal",
"boardTabs": [
{
"id": "california",
"label": "California"
},
{
"id": "sf",
"label": "Bay Area"
},
{
"id": "socal",
"label": "SoCal"
}
],
"chapters": [
{
"index": 0,
"dataView": "all",
"dataViewIndex": "0",
"number": "01",
"shortLabel": "Whole Board"
},
{
"index": 1,
"dataView": "dtla",
"dataViewIndex": "1",
"number": "02",
"shortLabel": "DTLA"
},
{
"index": 2,
"dataView": "westside",
"dataViewIndex": "2",
"number": "03",
"shortLabel": "Westside"
},
{
"index": 3,
"dataView": "hollywood",
"dataViewIndex": "3",
"number": "04",
"shortLabel": "Hollywood"
},
{
"index": 4,
"dataView": "valley",
"dataViewIndex": "4",
"number": "05",
"shortLabel": "The Valley"
},
{
"index": 5,
"dataView": "harbour",
"dataViewIndex": "5",
"number": "06",
"shortLabel": "Harbour"
},
{
"index": 6,
"dataView": "orange-county",
"dataViewIndex": "6",
"number": "07",
"shortLabel": "Orange County"
},
{
"index": 7,
"dataView": "inland-empire",
"dataViewIndex": "7",
"number": "08",
"shortLabel": "Riverside"
}
]
}
}
}
+123 -7
View File
@@ -9,6 +9,17 @@
*
* node scripts/look.mjs <name> [--url <path-and-query>] [--phone] [--at <iso>]
* [--wait <ms>] [--click <text>] [--api <origin>]
* [--chapter <data-view>] [--expect <short label>]
*
* `--chapter` aims at a chapter by its **identity** — the `data-view` attribute
* the button carries, which is the chapter's id in the city pack — and not by
* its position in the list or by the text printed on it. Both of the other two
* have already produced a photograph of somewhere else in this repo: an index
* because a pack was reordered under it, and button text because two of
* California's six chapters are *doors* whose labels ("LA", "SF") match a
* click and then leave the board entirely. `--expect` asserts the printed short
* label as a second, independent check on the same button, which is what
* `scripts/brand-assets/shots.mjs` does and is worth keeping.
*
* Writes /tmp/tera-look/<name>.png. `--at` pins the clock, because the sun's
* position is computed from the real one and a shot taken at 03:00 tells you
@@ -51,12 +62,45 @@ const PRESETS = {
"sf-night": { url: "/?city=sf", at: "2026-08-23T04:35:00Z" },
"sky-night": { url: "/?city=california", at: "2026-08-23T04:35:00Z" },
"socal-night": { url: "/?city=socal", at: "2026-08-23T04:35:00Z" },
// The aeroplane glyph, at the four stand-offs its clamp has to serve: a whole
// board, two chapter closeups on that board, and a detailed metro.
"glyph-board": { url: "/?city=california" },
"glyph-la": { url: "/?city=california", click: "^LA$" },
"glyph-sf": { url: "/?city=california", click: "^SF$" },
"glyph-bay": { url: "/?city=sf", click: "The Bay" },
/*
* The aeroplane glyph, at the four stand-offs its clamp has to serve.
*
* TWO OF THESE WERE PHOTOGRAPHS OF SOMEWHERE ELSE, AND THE SET AS A WHOLE WAS
* DESCRIBED WRONG. The old comment read "a whole board, two chapter closeups
* on that board, and a detailed metro", and `glyph-la` / `glyph-sf` were
* `click: "^LA$"` / `click: "^SF$"` — the California board's chapters 04 and
* 05. Neither of those is a camera pose. Both are matched against
* `CALIFORNIA_DESTINATIONS` in `main.ts` and call `switchCity()` rather than
* `flyTo()`, so clicking them left California entirely: `glyph-la` returned a
* SoCal frame and `glyph-sf` a Bay Area one, byte-comparable to `hero-socal`
* and `hero-sf`. Two of the four stand-offs the set claimed to cover were
* duplicates of two others.
*
* Photographing the fix turned up the rest of it. California's six chapters
* are one whole-board pose, TWO DRIVES (`la-sf-us-101` and `la-sf-i-5` call
* `requestControlMode("drive")` at `main.ts:4493`, so clicking either lands a
* chase camera on a freeway, not a stand-off), two doors, and the north. The
* state board therefore has exactly TWO aerial poses on it — `State` and
* `North` — and "two chapter closeups on that board" was never available.
*
* So the clamp's four stand-offs are spread across the boards that have them,
* which is a better ladder anyway: three orders of magnitude of stand-off,
* every rung an aerial camera, every rung aimed by `data-view`.
*/
"glyph-board": { url: "/?city=california", chapter: "california-overview", expect: "State" },
"glyph-north": { url: "/?city=california", chapter: "shasta-cascades", expect: "North" },
"glyph-bay": { url: "/?city=sf", chapter: "bay-area", expect: "The Bay" },
"glyph-fidi": { url: "/?city=sf", chapter: "fidi", expect: "FiDi" },
/*
* The corridor, which is a drive and is photographed as one.
*
* These are what `glyph-la` and `glyph-sf` now resolve to: the two California
* chapters that stay on the California board. They are named for what they
* are rather than for the glyph, because the frame they produce is a chase
* camera behind the EV and no aeroplane is in it.
*/
"corridor-101": { url: "/?city=california", chapter: "la-sf-us-101", expect: "101" },
"corridor-i5": { url: "/?city=california", chapter: "la-sf-i-5", expect: "I-5" },
// The opening move, landed. `--reduced` collapses it to a cut, which is what
// makes an arrival frame reproducible.
"hero-california": { url: "/?city=california" },
@@ -64,7 +108,30 @@ const PRESETS = {
"hero-sf": { url: "/?city=sf" },
};
const preset = PRESETS[name] ?? {};
/**
* Old names that still have to answer, pointed at what they always meant.
*
* Deleting them would be tidier and worse: `glyph-la` appears in the write-ups
* that commissioned these frames, and a name that 404s sends somebody back to
* `--click "^LA$"`, which is the exact command that produced the wrong picture.
* An alias that says out loud what it resolved to cannot do that.
*/
const ALIASES = {
"glyph-la": "corridor-101",
"glyph-sf": "corridor-i5",
};
const resolved = ALIASES[name] ?? name;
if (resolved !== name) {
console.log(
`look: "${name}" is an alias for "${resolved}". The California board's LA and SF chapters ` +
`are doors into the metro boards, not camera poses, so a preset named for them used to ` +
`photograph the other board. The two chapters that stay on California are the corridor ` +
`legs, and both open in DRIVE mode — for the aeroplane glyph's aerial stand-offs use ` +
`glyph-board, glyph-north, glyph-bay, glyph-fidi.`,
);
}
const preset = PRESETS[resolved] ?? {};
const flag = (f, d) => {
const i = args.indexOf(f);
if (i !== -1) return args[i + 1];
@@ -249,6 +316,55 @@ try {
/* already dismissed, or not shown */
}
/**
* The chapter, by identity, before any `--click`.
*
* `flyTo` eases over about two seconds of scene time, so the wait after it is
* the same 8 s a click gets. The label check is a hard failure rather than a
* log line: a preset that silently photographs the wrong chapter is worse than
* one that refuses, because the picture still looks deliberate.
*/
const chapter = flag("--chapter", null);
if (chapter !== null && chapter !== "") {
const expect = flag("--expect", null);
const found = await page.evaluate((id) => {
const button = document.querySelector(`#chapters .chapter[data-view="${id}"]`);
if (!(button instanceof HTMLElement)) {
return {
ok: false,
available: [...document.querySelectorAll("#chapters .chapter")].map((node) =>
node.getAttribute("data-view"),
),
};
}
const spans = [...button.querySelectorAll("span")];
const label = (spans[spans.length - 1]?.textContent ?? "").trim();
const index = button.getAttribute("data-view-index");
button.click();
return { ok: true, label, index };
}, chapter);
if (!found.ok) {
console.error(
`look: no chapter with data-view="${chapter}" on this board — it has ` +
`${JSON.stringify(found.available)}. Not photographing a frame nobody asked for.`,
);
await browser.close();
shutdown();
process.exit(1);
}
if (expect !== null && found.label !== expect) {
console.error(
`look: chapter "${chapter}" prints "${found.label}", the preset expects "${expect}" — ` +
`a pack was re-labelled, so this shot would be captioned wrong.`,
);
await browser.close();
shutdown();
process.exit(1);
}
console.log(`look: chapter ${chapter} — "${found.label}" at position ${found.index}`);
await page.waitForTimeout(8000);
}
/**
* `--click` may be given more than once, and they run in order.
*
+338
View File
@@ -0,0 +1,338 @@
/**
* What one statewide board would actually cost, measured rather than argued.
*
* node scripts/merge-feasibility.mjs [--no-still] [--out <dir>]
*
* **Nothing here is wired into the product.** No board id, no route, no flag
* that reaches a deploy. It builds the merged pack in node, counts what comes
* out, and takes one photograph — and the photograph is the deliverable, because
* the decision it supports is not an engineering one.
*
* ## The question
*
* "Why are there still three boards" has an answer that costs nothing —
* `cities/reconcile.ts`, four data rules — and an answer that costs a
* rebuild: one pack, one lattice, one lot size, from Yreka to the border.
* The second one is priced here.
*
* A lot is `LOT = 0.42` **scene units** (`blocks.ts`), so how much ground a
* building stands on is decided entirely by the board's metres-per-unit: 40 m on
* the Bay Area, 164 m on Southern California, 806 m on the state. A merged board
* therefore has to pick one, and the pick is the whole decision:
*
* - at Bay Area density the state is **dead** — the anonymous city alone is
* tens of millions of triangles against a 2.6M desktop cap;
* - at Southern California density it **fits**, and San Francisco is drawn the
* way Southern California is drawn now: 164 m lots instead of 40 m.
*
* The second is a real option and it costs the product its best-looking board.
* That trade is the owner's to make against a picture, which is why this ends
* with `--out/merge-sf-40m-vs-164m.png` rather than with a recommendation.
*
* ## What the still actually varies
*
* `LOT`, not `latScale`. Rescaling San Francisco's pack would move its camera,
* its terrain lattice and every chapter pose at once, and the resulting pair
* would differ in a dozen ways of which lot size was one. Changing `LOT` to
* `0.42 × (390.6 / 94.34) = 1.7392` in a **throwaway tree under /tmp** puts
* exactly 164 m of ground under a building and leaves the frame otherwise
* identical — which is the comparison that was asked for. `blocks.ts` in this
* repo is not touched, and `git status` after a run says so.
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { performance } from "node:perf_hooks";
import CALIFORNIA from "../src/cities/california.ts";
import SF from "../src/cities/sf.ts";
import SOCAL from "../src/cities/socal.ts";
import { createBlocks } from "../src/engine/blocks.ts";
import { createTerrain } from "../src/engine/terrain.ts";
import { World } from "../src/engine/world.ts";
const args = process.argv.slice(2);
const flag = (name, fallback) => {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
};
const OUT = flag("--out", "/tmp/tera-look/merge");
const STILL = !args.includes("--no-still");
mkdirSync(OUT, { recursive: true });
const REPO = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const BUDGETS = JSON.parse(readFileSync(`${REPO}/scripts/performance-budgets.json`, "utf8"));
// ---- The merged pack --------------------------------------------------------
/** Does a lat/lng fall inside a board's rectangle? */
const inside = (city, lat, lng) =>
lat >= city.bounds.minLat && lat <= city.bounds.maxLat &&
lng >= city.bounds.minLng && lng <= city.bounds.maxLng;
const centroid = (polygon) => {
let lat = 0;
let lng = 0;
for (const [a, b] of polygon) {
lat += a;
lng += b;
}
return [lat / polygon.length, lng / polygon.length];
};
/**
* One California, at whatever density the caller asks for.
*
* The two metro rectangles do not intersect and California contains both, so
* merging the districts is a union with one subtraction: a state district whose
* centre falls inside a metro board is dropped, because the metro pack draws
* that ground in far more detail. That is 116 authored districts before the
* subtraction — which is the number that kills a per-axis focus lattice, since
* two focus regions at opposite corners refine nearly the whole board.
*/
function merged(metresPerUnit, dropCovered = true) {
const latScale = 111_320 / metresPerUnit;
const metros = [SF, SOCAL];
const covered = (polygon) => {
const [lat, lng] = centroid(polygon);
return metros.some((city) => inside(city, lat, lng));
};
const districts = [
...CALIFORNIA.districts.filter((d) => !dropCovered || !covered(d.polygon)),
...SF.districts,
...SOCAL.districts,
];
return {
...CALIFORNIA,
id: "california-merged",
name: "California",
latScale,
// California's own lattice, unchanged: it is the only ground cell that
// covers 989,000 km² at all. Southern California's coarse cell over the
// state is 1.6M lattice points and 21 s of single-threaded field.
landmasses: [...CALIFORNIA.landmasses, ...SF.landmasses, ...SOCAL.landmasses],
inlandWater: [...CALIFORNIA.inlandWater, ...SF.inlandWater, ...SOCAL.inlandWater],
parks: [...CALIFORNIA.parks, ...SF.parks, ...SOCAL.parks],
hills: [...CALIFORNIA.hills, ...SF.hills, ...SOCAL.hills],
districts,
roads: [...CALIFORNIA.roads, ...SF.roads, ...SOCAL.roads],
landmarks: [...CALIFORNIA.landmarks, ...SF.landmarks, ...SOCAL.landmarks],
bridges: [...SF.bridges, ...SOCAL.bridges],
airports: [...(SF.airports ?? []), ...(SOCAL.airports ?? [])],
ports: [...(SF.ports ?? []), ...(SOCAL.ports ?? [])],
reconciled: true,
};
}
const triangles = (mesh) => {
const geometry = mesh.geometry;
const perInstance = geometry.index ? geometry.index.count / 3 : geometry.attributes.position.count / 3;
return perInstance * (mesh.isInstancedMesh ? mesh.count : 1);
};
function measure(label, metresPerUnit, dropCovered = true) {
const city = merged(metresPerUnit, dropCovered);
const world = new World(city);
// `createBlocks` samples the land and park masks, so the field is built here,
// lazily, on this thread — which is also where the placement time below is
// measured from, and is why the field build is outside the timer.
world.lattice();
const t0 = performance.now();
const blocks = createBlocks(world);
const placementMs = performance.now() - t0;
const lat = (city.bounds.maxLat - city.bounds.minLat) * city.latScale;
const lng = (city.bounds.maxLng - city.bounds.minLng) * world.lngScale;
return {
label,
metresPerUnit,
lotMetres: 0.42 * metresPerUnit,
extent: [lng, lat],
districts: city.districts.length,
instances: blocks.count,
triangles: triangles(blocks),
placementMs,
world,
};
}
console.log("=== the merged pack, built and counted ===\n");
console.log("A lot is 0.42 scene units, so lot size in metres is the board's scale and nothing else.");
console.log("These are the ANONYMOUS CITY only — `createBlocks`, one instanced mesh — which is the");
console.log("layer that scales with density and the layer that decides the answer.\n");
const socalDensity = measure("SoCal density", 111_320 / 285);
const bayDensity = measure("Bay Area density", 111_320 / 1180);
/**
* The same two boards without the subtraction, because the subtraction is a
* judgement and the reader should be able to see what it is worth.
*
* Keeping all 116 authored districts means the state pack's coarse Bay Area and
* Los Angeles polygons place a second, 164 m city on top of the metro packs'
* own. It is the wrong merge — but it is the honest upper bound, and quoting a
* number without saying which of the two it is has already produced one 1.8x
* disagreement in this round's own brief.
*/
const socalDensityAll = measure("SoCal density, all 116 districts", 111_320 / 285, false);
const bayDensityAll = measure("Bay Area density, all 116 districts", 111_320 / 1180, false);
const cap = BUDGETS.scenes["bay-area"].desktop.maxTriangles;
for (const row of [socalDensity, socalDensityAll, bayDensity, bayDensityAll]) {
console.log(`${row.label}`);
console.log(` metres per unit ${row.metresPerUnit.toFixed(2)} lot ${row.lotMetres.toFixed(0)} m`);
console.log(` board extent ${row.extent[0].toFixed(0)} x ${row.extent[1].toFixed(0)} units`);
console.log(` districts ${row.districts}`);
console.log(` instances ${row.instances.toLocaleString("en-US")}`);
console.log(` triangles ${row.triangles.toLocaleString("en-US")}` +
` (${(row.triangles / cap).toFixed(2)}x the bay-area desktop cap of ${cap.toLocaleString("en-US")})`);
console.log(` placement ${row.placementMs.toFixed(0)} ms on the main thread (NOISY — see below)\n`);
}
/**
* Read the counts, not the clock.
*
* Instances and triangles are a function of the pack and the seeded RNG and come
* back identical to the digit on every run. The placement millisecond does not:
* five runs of the *same* build on this box gave 323, 554, 617, 909 and 1,989 ms
* for the same 93,253 lots — a factor of six, on a box whose GPU never leaves
* 500 MHz of a possible 2,725 and which usually has two other agents on it. The
* brief's 432 ms is inside that spread and so is almost anything else. Quote the
* counts; treat the milliseconds as an order of magnitude.
*/
console.log("Placement time on this box spans ~320-2,000 ms for the same 93,253 lots across runs.");
console.log("The instance and triangle counts are deterministic to the digit. Trust those.\n");
// The terrain the merged board would carry, on California's own lattice. Held
// back until after the block counts because it is the cheap half and saying so
// in the wrong order invites the wrong conclusion.
{
const t0 = performance.now();
const terrain = createTerrain(socalDensity.world);
const ms = performance.now() - t0;
let tris = 0;
terrain.traverse((node) => {
if (node.isMesh) tris += triangles(node);
});
console.log(`terrain, on California's 3,473 m cell: ${tris.toLocaleString("en-US")} triangles, ${ms.toFixed(0)} ms`);
console.log(`whole merged board at SoCal density: ${(tris + socalDensity.triangles).toLocaleString("en-US")} triangles\n`);
}
console.log("Today, for comparison — measured by scripts/performance-budget.mjs at bcac6aa:");
console.log(" california 375,351 tri / 374 draws socal 1,429,993 / 218 bay-area 2,264,956 / 209\n");
// ---- The picture ------------------------------------------------------------
if (!STILL) {
console.log("merge-feasibility: --no-still, stopping before the photograph");
process.exit(0);
}
/**
* Two builds of the same commit, differing in one constant.
*
* `git archive` rather than a copy of the working tree: the point of the
* picture is what a lot size does, and a working tree with three agents in it
* is not a controlled variable.
*/
const SCRATCH = `${OUT}/scratch`;
const COARSE_LOT = (0.42 * (111_320 / 285)) / (111_320 / 1180);
const trees = [
{ name: "sf-40m", lot: null },
{ name: "sf-164m", lot: COARSE_LOT },
];
for (const tree of trees) {
const root = `${SCRATCH}/${tree.name}`;
rmSync(root, { recursive: true, force: true });
mkdirSync(root, { recursive: true });
execFileSync("bash", ["-c", `cd ${REPO} && git archive HEAD | tar -x -C ${root}`]);
execFileSync("ln", ["-sfn", `${REPO}/node_modules`, `${root}/node_modules`]);
if (tree.lot !== null) {
const path = `${root}/src/engine/blocks.ts`;
const source = readFileSync(path, "utf8");
const next = source.replace(
/^const LOT = 0\.42;.*$/m,
`const LOT = ${tree.lot.toFixed(6)}; // 164 m at SF's scale — merge-feasibility.mjs, throwaway tree`,
);
if (next === source) throw new Error("merge-feasibility: could not find LOT in blocks.ts");
writeFileSync(path, next);
}
execFileSync("npx", ["vite", "build", "--outDir", `${OUT}/dist-${tree.name}`, "--emptyOutDir"], {
cwd: root,
stdio: "ignore",
});
console.log(`merge-feasibility: built ${tree.name}${tree.lot === null ? "" : ` (LOT ${tree.lot.toFixed(4)})`}`);
}
const { chromium } = await import("playwright");
const { spawn } = await import("node:child_process");
const { createServer } = await import("node:net");
async function shoot(dist, out) {
const port = await new Promise((resolve, reject) => {
const probe = createServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const { port: p } = probe.address();
probe.close(() => resolve(p));
});
});
const server = spawn(`${REPO}/node_modules/.bin/vite`,
["preview", "--outDir", dist, "--port", String(port), "--strictPort"],
{ detached: true, stdio: ["ignore", "pipe", "pipe"], cwd: REPO });
await new Promise((resolve) => {
const settle = setTimeout(resolve, 30000);
const read = (chunk) => {
if (/http:\/\/(?:localhost|127\.0\.0\.1):\d+/.test(String(chunk))) {
clearTimeout(settle);
resolve();
}
};
server.stdout.on("data", read);
server.stderr.on("data", read);
});
const browser = await chromium.launch({
channel: "chrome",
args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
});
const context = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
timezoneId: "America/Los_Angeles",
reducedMotion: "reduce",
});
await context.clock.setFixedTime(new Date("2026-08-21T20:00:00Z"));
const page = await context.newPage();
await page.goto(`http://localhost:${port}/?city=sf`, { waitUntil: "networkidle", timeout: 60000 });
await page.waitForTimeout(11000);
try {
await page.getByText(/^Skip$/).first().click({ timeout: 2000 });
await page.waitForTimeout(1200);
} catch {
/* not shown */
}
// Downtown, by identity rather than by position in the list.
await page.locator('.chapter[data-view="fidi"]').first().click({ timeout: 5000 });
await page.waitForTimeout(7000);
await page.screenshot({ path: out, clip: { x: 340, y: 0, width: 860, height: 900 } });
await browser.close();
try {
process.kill(-server.pid, "SIGTERM");
} catch {
/* already gone */
}
}
const halves = [];
for (const tree of trees) {
const path = `${OUT}/${tree.name}.png`;
await shoot(`${OUT}/dist-${tree.name}`, path);
halves.push(path);
console.log(`merge-feasibility: shot ${path}`);
}
const still = `${OUT}/merge-sf-40m-vs-164m.png`;
execFileSync("montage", [...halves, "-tile", "2x1", "-geometry", "+4+4", "-background", "#101418", still]);
console.log(`\nmerge-feasibility: ${still}`);
console.log("Left: San Francisco today, 40 m lots. Right: the same view at 164 m lots — what");
console.log("San Francisco looks like on a statewide board that fits. That picture is the decision.");
if (!existsSync(still)) process.exit(1);
+56 -5
View File
@@ -16,12 +16,54 @@ const VIEWPORTS = {
desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
};
/*
* EVERY SCENE NAMES THE BOARD IT MEANS, AND CHECKS IT GOT IT.
*
* `?city=` falls back to the first board rather than failing on an unknown id —
* deliberately, so a deep link to a city this build does not have shows a city
* instead of a black screen — and that fallback is silent. A `bay-area` cell
* that quietly measured California would pass its 2.6M-triangle cap by a factor
* of six and would keep passing through any regression a phone would choke on:
* a budget that cannot fail. So every scene names a `signature` — one chapter
* `data-view` that exists on that board and on no other — and does not become
* ready until it is on screen.
*
* A SIGNATURE CHAPTER RATHER THAN THE BOARD TAB, deliberately. The obvious check
* is the `data-board` of the pressed tab, and it was written that way first; it
* broke within the hour, because the tab strip is chrome and chrome is exactly
* what the continuity work is allowed to change. A chapter id is pack data, it
* is what `scripts/fixtures/chapter-identity.json` pins, and it is what every
* capture aim resolves to. `hayes-valley` and `dtla` are unique across the three
* packs; `all` is not, which is why neither metro is identified by its first
* chapter.
*
* `california-drive` used to reach its chapter with
* `document.querySelectorAll("#chapters .chapter")[1].click()`. That is an
* unguarded index into pack data, and it is the exact pattern that once shipped
* a share card of a chase camera on a freeway under the headline "Cities from
* above" — see the note at `scripts/brand-assets/capture.mjs:70`. It now aims at
* `data-view="la-sf-us-101"` and asserts the printed label, so a reordered pack
* stops the run instead of quietly measuring a different scene under the old
* scene's caps.
*/
const SCENES = {
california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 },
california: {
host: "tera.lumbridgecorp.com",
signature: "california-overview",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='california-overview']") !== null,
},
"california-drive": {
host: "tera.lumbridgecorp.com",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 1,
activate: () => document.querySelectorAll("#chapters .chapter")[1]?.click(),
signature: "california-overview",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='california-overview']") !== null && document.querySelector("#chapters .chapter[data-view='la-sf-us-101']") !== null,
activate: () => {
const button = document.querySelector("#chapters .chapter[data-view='la-sf-us-101']");
if (button === null) throw new Error("no chapter with data-view='la-sf-us-101' on this board");
const spans = [...button.querySelectorAll("span")];
const label = (spans[spans.length - 1]?.textContent ?? "").trim();
if (label !== "101") throw new Error(`chapter la-sf-us-101 prints "${label}", not "101" — the pack was re-labelled`);
button.click();
},
active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false,
},
office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true },
@@ -45,12 +87,14 @@ const SCENES = {
"bay-area": {
host: "tera.lumbridgecorp.com",
query: "?city=sf",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0,
signature: "hayes-valley",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='hayes-valley']") !== null,
},
socal: {
host: "tera.lumbridgecorp.com",
query: "?city=socal",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0,
signature: "dtla",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='dtla']") !== null,
},
};
@@ -408,6 +452,13 @@ async function measure(browser, port, sceneName, viewportName, budget, requestLo
bootHidden: document.getElementById("boot")?.hidden ?? null,
bootText: document.getElementById("boot")?.textContent?.trim().slice(0, 240) ?? null,
chapters: document.querySelectorAll("#chapters .chapter").length,
// Which chapters actually answered. A readiness timeout on a metro cell
// is now most likely to mean `?city=` fell back to California, and
// reading the ids back makes that a one-word diagnosis rather than a
// twenty-minute one. `activeBoard` is best-effort: the board tab strip
// is chrome and may not exist.
views: [...document.querySelectorAll("#chapters .chapter")].map((node) => node.getAttribute("data-view")),
activeBoard: document.querySelector("[data-board][aria-pressed='true']")?.getAttribute("data-board") ?? null,
enter: document.getElementById("enter")?.textContent?.trim() ?? null,
})).catch(() => null);
throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error });
+1
View File
@@ -1,5 +1,6 @@
{
"version": 1,
"note": "Caps are never raised. A red p95FrameIntervalMs on this box is noise before it is a finding — the GPU here never leaves 500 MHz of a possible 2,725, so a desktop cell on the vsync deadline flips between 16.8 and 33.3 ms with geometry identical to the digit; judge on maxTriangles and maxDrawCalls. The five scene names are bound to board identity in performance-budget.mjs: every scene asserts the data-board of the pressed tab, because ?city= falls back to the first board rather than failing and a bay-area cell that silently measured California would pass its cap by a factor of six.",
"scenes": {
"california": {
"desktop": {
+169
View File
@@ -0,0 +1,169 @@
/**
* The before/after pair, at every authored chapter of all three packs.
*
* `TERA_RECONCILE` exists so that four data changes can be *looked at* rather
* than argued about, and the acceptance for every one of them is a photograph:
* a board that is worse in the picture means that rule reverts. Twenty-six
* chapters times two flag states is fifty-two frames, which is more than anyone
* will take one `look.mjs` invocation at a time — so this is one command, one
* preview and one browser, and it names its output after the chapter's identity
* rather than after its position in a list.
*
* node scripts/reconcile-shots.mjs [--dist <dir>] [--out <dir>]
* [--rules <a,b>] [--only <board>]
* [--at <iso>] [--wait <ms>]
*
* `--rules` is passed through as `?reconcile=<rules>`, so a single rule can be
* photographed alone — which is the point of the four being independently
* switchable in the first place.
*
* The port and build checks are `look.mjs`'s, for `look.mjs`'s reason: this box
* once accumulated a hundred and forty-seven abandoned previews in an afternoon
* and photographed somebody else's dist through one of them.
*/
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { createServer } from "node:net";
import { chromium } from "playwright";
import CALIFORNIA from "../src/cities/california.ts";
import SF from "../src/cities/sf.ts";
import SOCAL from "../src/cities/socal.ts";
const args = process.argv.slice(2);
const flag = (name, fallback) => {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
};
const DIST = flag("--dist", "dist");
const OUT = flag("--out", "/tmp/tera-look/reconcile");
const RULES = flag("--rules", "1");
const ONLY = flag("--only", null);
const AT = flag("--at", "2026-08-21T20:00:00Z");
const WAIT = Number(flag("--wait", "6000"));
const FIRST_WAIT = Number(flag("--first-wait", "11000"));
mkdirSync(OUT, { recursive: true });
/**
* The two California chapters that are doors, not poses.
*
* `main.ts` matches them against `CALIFORNIA_DESTINATIONS` and calls
* `switchCity()` rather than `flyTo()`, so clicking one leaves the board — which
* is what made `look.mjs`'s `glyph-la` and `glyph-sf` presets photographs of the
* wrong board for as long as they existed. They are shot anyway, because a
* before/after pair at *every* authored chapter is what was asked for and
* because the frame they produce is a real frame of the product; the page is
* reloaded afterwards so the next chapter is aimed from California again.
*/
const DOORS = new Set(["los-angeles", "san-francisco"]);
const BOARDS = [CALIFORNIA, SF, SOCAL].filter((city) => ONLY === null || city.id === ONLY);
const PORT = await new Promise((resolve, reject) => {
const probe = createServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
});
const server = spawn(
new URL("../node_modules/.bin/vite", import.meta.url).pathname,
["preview", "--outDir", DIST, "--port", String(PORT), "--strictPort"],
{ detached: true, stdio: ["ignore", "pipe", "pipe"] },
);
const shutdown = () => {
try {
process.kill(-server.pid, "SIGTERM");
} catch {
/* already gone */
}
};
process.on("exit", shutdown);
const bound = await new Promise((resolve) => {
let seen = "";
const settle = setTimeout(() => resolve(null), 30000);
const read = (chunk) => {
seen += String(chunk);
const match = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen);
if (match) {
clearTimeout(settle);
resolve(Number(match[1]));
}
};
server.stdout.on("data", read);
server.stderr.on("data", read);
});
if (bound !== PORT) {
console.error(`reconcile-shots: preview bound ${bound}, not ${PORT} — refusing to photograph it`);
shutdown();
process.exit(1);
}
const browser = await chromium.launch({
channel: "chrome",
args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
});
const context = await browser.newContext({
viewport: { width: 1600, height: 1000 },
deviceScaleFactor: 1,
timezoneId: "America/Los_Angeles",
// The opening arrival collapses to a cut under this, which is the only way
// fifty-two frames taken over half an hour are the same fifty-two frames when
// the box is busy.
reducedMotion: "reduce",
});
await context.clock.setFixedTime(new Date(AT));
const page = await context.newPage();
const errors = [];
page.on("console", (m) => {
if (m.type() === "error") errors.push(m.text());
});
async function land(url) {
await page.goto(`http://localhost:${PORT}${url}`, { waitUntil: "networkidle", timeout: 60000 });
await page.waitForTimeout(FIRST_WAIT);
try {
await page.getByText(/^Skip$/).first().click({ timeout: 2000 });
await page.waitForTimeout(1200);
} catch {
/* already dismissed, or not shown */
}
}
let taken = 0;
for (const city of BOARDS) {
for (const state of ["off", "on"]) {
const url = `/?city=${city.id}${state === "on" ? `&reconcile=${RULES}` : ""}`;
await land(url);
for (const chapter of city.chapters) {
// Aim by identity. `data-view` is the chapter's own id, so a pack that is
// reordered under this script still photographs the chapter it names —
// the failure `look.mjs`'s two broken presets were an instance of.
const button = page.locator(`.chapter[data-view="${chapter.id}"]`).first();
try {
await button.click({ timeout: 5000 });
} catch {
console.log(`reconcile-shots: could not click ${city.id}/${chapter.id}`);
await land(url);
continue;
}
await page.waitForTimeout(WAIT);
const path = `${OUT}/${city.id}-${chapter.id}-${state}.png`;
await page.screenshot({ path });
taken += 1;
console.log(`reconcile-shots: ${path}`);
if (DOORS.has(chapter.id)) await land(url);
}
}
}
const real = errors.filter((e) => !/404|Failed to load resource/.test(e));
console.log(`reconcile-shots: ${taken} frames into ${OUT}`);
console.log(real.length === 0 ? "reconcile-shots: no console errors" : `reconcile-shots: ERRORS ${JSON.stringify([...new Set(real)].slice(0, 5))}`);
await browser.close();
shutdown();
process.exit(0);
+733
View File
@@ -0,0 +1,733 @@
#!/usr/bin/env node
/**
* What a board switch actually costs, in the four numbers that decide whether
* retaining a board was worth it.
*
* node scripts/switch-cost.mjs # all six directions, once each
* node scripts/switch-cost.mjs --repeat 3 # median of three per direction
* node scripts/switch-cost.mjs --only california->sf
* node scripts/switch-cost.mjs --json /tmp/switch-cost.json
* node scripts/switch-cost.mjs --dist /some/other/dist
*
* ## Why this exists, and why the frame timer could not answer it
*
* `performance-budget.mjs` measures a *settled* board: it waits `warmup-ms`,
* throws away everything before that, and samples a stationary camera. That is
* the right instrument for "is this board affordable" and it is structurally
* incapable of seeing a board *change*, because the change happens entirely
* inside the window it discards. So the most-complained-about moment in the
* product — the pause between one board and the next — was the one moment
* nothing measured. Every claim about it was a feeling.
*
* It is also the moment where this box's frame timer lies worst. The GPU here
* never leaves 500 MHz of a possible 2,725, so p95 frame interval flips between
* 16.8 and 33.3 ms on fragment changes with geometry identical to the digit.
* None of the four numbers below is a frame time. They are wall-clock cover,
* main-thread block, the single worst task, and how many frames the page
* managed to paint — and every one of them is a count or a duration that a
* throttled GPU does not move.
*
* ## The numbers
*
* **coveredMs** — from the gesture to the end of the switch, where the end is
* whichever comes last: the destination board's chapters appearing, or the boot
* card coming off. Today the card is last, because `building()` in `main.ts`
* adds `.done` and hides the card 300 ms later; `opaqueMs` is reported beside it
* as click-to-`.done`, the part that is fully opaque.
*
* **arrivalMs and card** — when the destination was on screen, and whether a
* boot card was raised at all. These two are the *shape* of the switch rather
* than its length, and they are the pair that tells you which world you are in:
* `card=yes` with `arrival ≈ covered - 300` is the disposal model, and `card=no`
* with `blank` near zero is retention. Arrival is detected by watching
* `#chapters` for the destination's signature chapter — not by the card, which
* retention removes, and not by the board tab, which the continuity work
* removes.
*
* **blockedMs** — the sum of Long Tasks inside that window. This is the part
* that is genuinely frozen: no input, no animation, no clock. Everything else
* in `coveredMs` is a live, animating page that simply has nothing to draw.
* The gap between the two is the prize: it is the fraction of the pause that
* exists only because the outgoing board was disposed before the incoming one
* was built, and retention deletes it without a single new triangle.
*
* **longestTaskMs** — the worst single task. Sums are consoling and a 512 ms
* task is what a hitch feels like, so the maximum is reported separately and is
* the number to watch after retention lands: today it hides inside a full-screen
* card with a percentage on it, which reads as *busy*; behind a live picture the
* same half second reads as a *hang*.
*
* **frames / blankFrames** — frames the page painted during the switch, and how
* many of them issued zero WebGL draw calls. A blank frame is the compositor
* doing its job over a scene that no longer exists. Two thirds blank is the
* disposal model — 46 of 69 on the baseline below — and that ratio collapsing
* toward zero is retention working, measured rather than felt.
*
* ## The baseline, measured
*
* `bcac6aa`, before any retention work, desktop 1440x900, Chrome/ANGLE/Vulkan on
* a Radeon RX 6700 XT, `--repeat 3` and the median of each column:
*
* | direction | covered | opaque | blocked | longest | frames | blank | live |
* |---|---|---|---|---|---|---|---|
* | california->sf | 1,715 | 1,415 | 608 | 526 | 69 | 46 | 65% |
* | socal->sf | 1,627 | 1,326 | 523 | 459 | 69 | 46 | 68% |
* | california->socal | 1,109 | 809 | 400 | 312 | 45 | 22 | 64% |
* | sf->socal | 1,027 | 726 | 328 | 245 | 44 | 21 | 68% |
* | sf->california | 1,120 | 820 | 340 | 196 | 49 | 26 | 70% |
* | socal->california | 1,061 | 761 | 319 | 205 | 47 | 24 | 70% |
*
* Two things to read off it before changing anything.
*
* **Cost is a property of the destination, not of the pair.** The two arrivals
* at the Bay Area differ by 5% and the two at California by 6%, while Bay Area
* against California is 1.6x. Nothing is being reused between boards today, and
* this table is what will show that changing.
*
* **Roughly two thirds of every pause is not work.** 64-70% of the cover is a
* live, animating page with an empty world in it, because the outgoing board was
* disposed before the incoming one was built. That is the fraction retention
* gets back without a single new triangle — and the residual, the 526 ms task on
* an arrival at the Bay Area, is a separate and nameable defect (main-thread
* instance placement for 83,137 buildings) that retention does not touch and
* will make *more* visible, because today it hides behind a full-screen card
* with a percentage on it and afterwards it will freeze a live picture.
*
* Reproduce it before trusting a change, on the same box, with nothing else
* competing for the GPU. This is a wall-clock instrument on a shared machine:
* treat 5% as noise and 2x as a finding.
*
* ## GPU flags
*
* Same ladder as `performance-budget.mjs` and `ui-smoke.mjs`, for the same
* reason: this box has an AMD card and no monitor, `--use-angle=vulkan` is what
* makes Chrome render headlessly on it at all, and the SwiftShader rung behind
* it keeps the script runnable on a CI box with no card. A software fall-back is
* reported in the output, because switch cost measured on SwiftShader is a
* different quantity and must not be compared to the table above.
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".webmanifest": "application/manifest+json",
};
const args = process.argv.slice(2);
const option = (name, fallback) => {
const at = args.indexOf(`--${name}`);
return at < 0 ? fallback : args[at + 1];
};
const has = (name) => args.includes(`--${name}`);
// A six-direction sweep is ten minutes of browser. `--help` must not start one:
// this file was asked for its usage once and answered by launching Chrome.
if (has("help") || args.includes("-h")) {
console.log(
[
"node scripts/switch-cost.mjs [options]",
"",
" --dist <path> build to measure (default ./dist)",
" --repeat <n> takes per direction, median reported (default 1)",
" --only <a->b> one direction, e.g. --only california->sf",
" --viewport <name> desktop | mobile (default desktop)",
" --settle-ms <ms> wait on the first board before switching (default 2500)",
" --timeout-ms <ms> per-wait timeout (default 90000)",
" --json <path> write the full result, samples included",
" --software SwiftShader only; NOT comparable to the GPU baseline",
" --headed watch it happen",
"",
"Reports, per direction: how long the switch covered the screen, when the",
"destination arrived, whether a boot card was raised at all, blocked",
"main-thread milliseconds, the longest single task, and how many painted",
"frames issued zero WebGL draw calls. See the header for the baseline.",
].join("\n"),
);
process.exit(0);
}
const DIST = resolve(option("dist", join(ROOT, "dist")));
const REPEAT = Math.max(1, Number(option("repeat", "1")) || 1);
const SETTLE_MS = Number(option("settle-ms", "2500")) || 2500;
const READY_TIMEOUT_MS = Number(option("timeout-ms", "90000")) || 90_000;
const ONLY = option("only", null);
const JSON_OUT = option("json", null);
const softwareOnly = has("software");
const headed = has("headed");
/**
* The three boards, and every ordered pair of them.
*
* Six directions rather than three, because the cost is not symmetric and the
* asymmetry is the interesting part: leaving the Bay Area is cheap and arriving
* at it is not, so a table that averaged the two would hide the only direction
* anybody complains about. `id` is the `?city=` value, which is also what the
* board tab and the places rung both carry.
*/
const BOARDS = [
/*
* `signature` is one chapter `data-view` that exists on this board and on no
* other, and it is how arrival is detected.
*
* Not the pressed board tab, which is what this file checked first and which
* stopped existing within the hour when the continuity work removed the tab
* strip. Not the boot card either: retention's whole point is that a switch
* stops raising one, so an instrument that timed "card up to card down" would
* report nothing at all on the build it exists to measure. A chapter id is
* pack data, it is what `scripts/fixtures/chapter-identity.json` pins, and the
* moment it appears in `#chapters` is the moment the destination board is on
* screen. `all` is shared by both metro boards, which is why neither is
* identified by its first chapter.
*/
{ id: "california", label: "California", signature: "california-overview" },
{ id: "sf", label: "the Bay Area", signature: "hayes-valley" },
{ id: "socal", label: "SoCal", signature: "dtla" },
];
const DIRECTIONS = BOARDS.flatMap((from) =>
BOARDS.filter((to) => to.id !== from.id).map((to) => ({ from, to })),
);
const VIEWPORTS = {
desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
};
const VIEWPORT_NAME = option("viewport", "desktop");
const VIEWPORT = VIEWPORTS[VIEWPORT_NAME];
if (!VIEWPORT) throw new Error(`--viewport must be one of ${Object.keys(VIEWPORTS).join(", ")}`);
// ---- The deployment: the built bundle and a server that says nothing -------
//
// Every optional source off, exactly as `ui-smoke.mjs` serves it. A switch that
// waits on a weather fetch is measuring somebody's uplink, and CONTRACT §0's
// visitor has no key anyway.
async function serve() {
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://local.invalid");
const json = (status, body) => {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body));
};
if (url.pathname === "/api/v1/health") {
return json(200, {
auth: { mode: "jwt", entryUrl: "/login.html" },
sources: { weather: "none", flights: "none", satellites: "none", markers: "none" },
regions: [],
});
}
if (url.pathname === "/api/v1/session") {
return json(200, { authenticated: false, subject: null, passwordLogin: true, admin: false });
}
if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" });
const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
const target = resolve(DIST, `.${relative}`);
if (!target.startsWith(`${resolve(DIST)}/`)) continue;
try {
const body = await readFile(target);
res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
res.end(body);
return;
} catch {
/* fall through to the SPA entry */
}
}
res.writeHead(404).end("not found");
});
await new Promise((ok, fail) => {
server.once("error", fail);
server.listen(0, "127.0.0.1", ok);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("no TCP port");
return { server, port: address.port };
}
// ---- Chrome ---------------------------------------------------------------
const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist"];
async function rendererOf(browser) {
const page = await browser.newPage();
try {
await page.goto("about:blank");
return await page.evaluate(() => {
const gl = document.createElement("canvas").getContext("webgl2");
const extension = gl?.getExtension("WEBGL_debug_renderer_info");
return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null;
});
} finally {
await page.close();
}
}
async function launch() {
const resolver = "--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1";
const ladder = softwareOnly
? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]]]
: [
["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]],
["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]],
];
let last;
for (const [backend, flags] of ladder) {
try {
const browser = await chromium.launch({
channel: "chrome",
headless: !headed,
args: [...COMMON_ARGS, resolver, ...flags],
});
const renderer = await rendererOf(browser);
const software = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer);
if (renderer !== null && (backend === "swiftshader" || !software)) {
return { browser, backend, renderer, software };
}
await browser.close();
} catch (error) {
last = error;
}
}
throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}
/**
* Everything measured is measured in the page, before any of the page's own
* script runs.
*
* Four instruments, and none of them is a frame time:
*
* - **Draw calls per painted frame.** The same four `drawArrays`/`drawElements`
* entry points `performance-budget.mjs` patches, counted per animation frame.
* A frame with zero of them is a frame with no world in it.
* - **Long Tasks.** `buffered: true`, so a task that started before the observer
* attached is still seen.
* - **The boot card.** A `MutationObserver` on `#boot`'s `hidden` and `class`,
* which is how `building()` covers and uncovers the screen. Observed rather
* than polled, because a poll inside a 512 ms task returns after it and would
* report the card as having gone away early.
* - **A mark for the click**, written in the same task as the click itself, so
* the window starts at the gesture rather than at Playwright's round trip.
*
* The observer has to wait for `#boot` to exist: this runs at document-start and
* the element is in `index.html`, so a `readystatechange` hook is enough and
* costs nothing on a page that is already parsed.
*/
function instrumentation() {
const state = {
frames: [],
longTasks: [],
boot: [],
chapters: [],
clickAt: null,
calls: 0,
longTaskSupported: false,
};
Object.defineProperty(globalThis, "__teraSwitchCost", { value: state });
const patch = (prototype, method) => {
if (!prototype || typeof prototype[method] !== "function") return;
const original = prototype[method];
if (original.__teraSwitchCostPatched) return;
const wrapped = function (...values) {
state.calls += 1;
return original.apply(this, values);
};
Object.defineProperty(wrapped, "__teraSwitchCostPatched", { value: true });
prototype[method] = wrapped;
};
for (const prototype of [
globalThis.WebGLRenderingContext?.prototype,
globalThis.WebGL2RenderingContext?.prototype,
]) {
for (const method of ["drawArrays", "drawElements", "drawArraysInstanced", "drawElementsInstanced"]) {
patch(prototype, method);
}
}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
state.longTasks.push({ startTime: entry.startTime, duration: entry.duration });
}
}).observe({ type: "longtask", buffered: true });
state.longTaskSupported = true;
} catch {
/* Long Tasks API is optional. */
}
requestAnimationFrame(function sample(now) {
state.frames.push({ at: now, calls: state.calls });
state.calls = 0;
requestAnimationFrame(sample);
});
const watchBoot = () => {
const card = document.getElementById("boot");
if (card === null) return false;
const snapshot = () =>
state.boot.push({
at: performance.now(),
hidden: card.hidden === true,
done: card.classList.contains("done"),
});
snapshot();
new MutationObserver(snapshot).observe(card, {
attributes: true,
attributeFilter: ["hidden", "class"],
});
return true;
};
if (!watchBoot()) document.addEventListener("readystatechange", watchBoot, { once: false });
/*
* When the destination board arrived, timestamped in the page.
*
* `#chapters` is replaced wholesale when a board mounts (`mount.ts` rebuilds
* it from a signature over the view list), so a `childList` observer on it
* fires once, at the moment the new board's chapters exist. That instant is
* "the new board is on screen" — independent of the boot card, which retention
* removes, and independent of the board tab strip, which the continuity work
* removes. Observed rather than polled: a poll inside a 512 ms task returns
* after the task and would credit the switch with time it did not take.
*/
const watchChapters = () => {
const nav = document.getElementById("chapters");
if (nav === null) return false;
const snapshot = () =>
state.chapters.push({
at: performance.now(),
views: [...nav.querySelectorAll(".chapter")].map((node) => node.getAttribute("data-view")),
});
snapshot();
new MutationObserver(snapshot).observe(nav, { childList: true, subtree: true });
return true;
};
if (!watchChapters()) document.addEventListener("readystatechange", watchChapters, { once: false });
}
const percentile = (values, p) => {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor(p * (sorted.length - 1)))];
};
const median = (values) => percentile(values, 0.5);
const round = (value) => (value === null || value === undefined ? null : Math.round(value));
/**
* One switch, from a settled board to another board.
*
* A fresh page per sample, deliberately. A second visit to a board reuses
* nothing today — `three` refcounts shader programs per material and deletes
* them at zero, so disposing a board deletes its programs and coming back
* relinks them — but that is a fact about today's engine, not a rule, and the
* whole point of this file is to be able to see it change. A shared page would
* bake the current answer into the instrument.
*/
async function measureSwitch(browser, port, from, to) {
const context = await browser.newContext({
viewport: { width: VIEWPORT.width, height: VIEWPORT.height },
deviceScaleFactor: VIEWPORT.deviceScaleFactor,
isMobile: VIEWPORT.isMobile,
hasTouch: VIEWPORT.hasTouch,
});
const page = await context.newPage();
const consoleErrors = [];
page.on("pageerror", (error) => consoleErrors.push(String(error)));
page.on("console", (message) => {
if (message.type() !== "error") return;
const at = message.location()?.url ?? "";
if (/\/api\/v1\//.test(at)) return;
consoleErrors.push(message.text());
});
await page.addInitScript(instrumentation);
try {
await page.goto(`http://tera.lumbridgecorp.com:${port}/?city=${from.id}`, {
waitUntil: "networkidle",
timeout: READY_TIMEOUT_MS,
});
const ready = () =>
document.getElementById("boot")?.hidden === true &&
document.querySelectorAll("#chapters .chapter").length > 0;
await page.waitForFunction(ready, null, { timeout: READY_TIMEOUT_MS });
await page.waitForTimeout(SETTLE_MS);
// The onboarding coach covers the board it is teaching you about, and a
// click that lands on its scrim is not a board switch.
await page
.getByText(/^Skip$/)
.first()
.click({ timeout: 1500 })
.then(() => page.waitForTimeout(600))
.catch(() => undefined);
/*
* The gesture, by whatever affordance this build offers for it.
*
* `[data-board]` is the three-tab board strip. `[data-place-board]` is a rung
* of the places list, which carries the board it belongs to. The continuity
* work replaces the first with the second — the tab strip is the loudest
* "you are somewhere else now" signal on the screen and it is being removed —
* so an instrument that only knew about tabs would stop working on exactly
* the change it exists to measure. Which one was used is reported, because
* they are not the same gesture: a tab selects a board and a rung selects a
* board *and a pose on it*.
*/
const clicked = await page.evaluate((boardId) => {
const state = globalThis.__teraSwitchCost;
state.frames.length = 0;
state.longTasks.length = 0;
state.boot.length = 0;
state.chapters.length = 0;
state.calls = 0;
const tab = document.querySelector(`[data-board="${boardId}"]`);
const rung = document.querySelector(`[data-place-board="${boardId}"]`);
const button = tab instanceof HTMLElement ? tab : rung instanceof HTMLElement ? rung : null;
if (button === null) return null;
state.clickAt = performance.now();
button.click();
return { at: state.clickAt, via: tab instanceof HTMLElement ? "board-tab" : "place-rung" };
}, to.id);
if (clicked === null) {
throw new Error(
`nothing on screen selects "${to.id}" — no [data-board="${to.id}"] and no ` +
`[data-place-board="${to.id}"]. If the affordance moved again, teach this file about it.`,
);
}
/*
* Wait for the destination, not for the cover.
*
* The signature chapter on screen and the boot card down. Both conditions,
* because today the card outlives the arrival by 300 ms and after retention
* there may be no card at all — and a wait written against the card alone
* would hang forever on the build this file exists to measure. Everything
* timed is timed by the in-page observers; this wait only decides when it is
* safe to read them.
*/
await page.waitForFunction(
(signature) =>
document.querySelector(`#chapters .chapter[data-view="${signature}"]`) !== null &&
document.getElementById("boot")?.hidden === true,
to.signature,
{ timeout: READY_TIMEOUT_MS },
);
const raw = await page.evaluate((signature) => {
const state = globalThis.__teraSwitchCost;
return {
clickAt: state.clickAt,
frames: state.frames.map((frame) => ({ at: frame.at, calls: frame.calls })),
longTasks: state.longTasks.map((task) => ({ ...task })),
boot: state.boot.map((entry) => ({ ...entry })),
chapterEvents: state.chapters.map((entry) => ({
at: entry.at,
arrived: entry.views.includes(signature),
})),
longTaskSupported: state.longTaskSupported,
views: [...document.querySelectorAll("#chapters .chapter")].map((node) =>
node.getAttribute("data-view"),
),
};
}, to.signature);
const start = raw.clickAt;
const raised = raw.boot.find((entry) => entry.at >= start && !entry.hidden) ?? null;
const done = raw.boot.find((entry) => entry.at >= start && entry.done) ?? null;
const cleared =
raised === null
? null
: (raw.boot.find((entry) => entry.at > raised.at && entry.hidden) ?? null);
const arrived = raw.chapterEvents.find((entry) => entry.at >= start && entry.arrived) ?? null;
/*
* The window runs from the gesture to whichever happened last: the
* destination's chapters appearing, or the cover coming off.
*
* Today the cover is last, because `building()` fades the card 300 ms after
* the board is up. Under retention there may be no card at all, and then
* arrival is the whole story. Taking the later of the two means the same
* command measures both worlds without an argument about which one it is in.
*/
const end = Math.max(arrived?.at ?? start, cleared?.at ?? start, start);
const inWindow = (at) => at >= start && at <= end;
const frames = raw.frames.filter((frame) => inWindow(frame.at));
const tasks = raw.longTasks.filter(
(task) => task.startTime + task.duration >= start && task.startTime <= end,
);
// Clipped to the window: a task that straddles the click contributes only
// the part of itself that froze the switch.
const blockedMs = tasks.reduce(
(sum, task) =>
sum + Math.max(0, Math.min(end, task.startTime + task.duration) - Math.max(start, task.startTime)),
0,
);
return {
// Landing is asserted on the destination's signature chapter, not on a
// board tab: the tab strip is chrome and the chapter id is pack data.
ok: raw.views.includes(to.signature),
landedOn: raw.views.includes(to.signature) ? to.id : (raw.views[0] ?? null),
chapters: raw.views.length,
via: clicked.via,
bootCardRaised: raised !== null,
coveredMs: end - start,
arrivalMs: arrived === null ? null : arrived.at - start,
opaqueMs: done === null ? null : done.at - start,
raiseLatencyMs: raised === null ? null : raised.at - start,
blockedMs,
longestTaskMs: tasks.length === 0 ? 0 : Math.max(...tasks.map((task) => task.duration)),
longTaskCount: tasks.length,
longTaskSupported: raw.longTaskSupported,
frames: frames.length,
blankFrames: frames.filter((frame) => frame.calls === 0).length,
consoleErrors,
};
} finally {
await context.close();
}
}
// ---- Run ------------------------------------------------------------------
const { server, port } = await serve();
const { browser, backend, renderer, software } = await launch();
const started = new Date().toISOString();
const rows = [];
let failed = false;
try {
for (const direction of DIRECTIONS) {
const name = `${direction.from.id}->${direction.to.id}`;
if (ONLY !== null && ONLY !== name) continue;
const samples = [];
for (let take = 0; take < REPEAT; take += 1) {
const sample = await measureSwitch(browser, port, direction.from, direction.to);
samples.push(sample);
if (!sample.ok) failed = true;
if (sample.consoleErrors.length > 0) failed = true;
}
const pick = (key) => median(samples.map((sample) => sample[key]).filter((v) => v !== null));
rows.push({
direction: name,
into: direction.to.label,
takes: samples.length,
via: [...new Set(samples.map((sample) => sample.via))].join("+"),
bootCard: samples.every((sample) => sample.bootCardRaised)
? "yes"
: samples.some((sample) => sample.bootCardRaised)
? "mixed"
: "no",
coveredMs: round(pick("coveredMs")),
arrivalMs: round(pick("arrivalMs")),
opaqueMs: round(pick("opaqueMs")),
blockedMs: round(pick("blockedMs")),
longestTaskMs: round(pick("longestTaskMs")),
longTaskCount: round(pick("longTaskCount")),
frames: round(pick("frames")),
blankFrames: round(pick("blankFrames")),
samples: samples.map((sample) => ({
via: sample.via,
bootCardRaised: sample.bootCardRaised,
coveredMs: round(sample.coveredMs),
arrivalMs: round(sample.arrivalMs),
opaqueMs: round(sample.opaqueMs),
blockedMs: round(sample.blockedMs),
longestTaskMs: round(sample.longestTaskMs),
frames: sample.frames,
blankFrames: sample.blankFrames,
ok: sample.ok,
landedOn: sample.landedOn,
consoleErrors: sample.consoleErrors,
})),
});
}
} finally {
await browser.close();
server.close();
}
const pad = (value, width) => String(value ?? "—").padStart(width);
const header = [
pad("direction", 22),
pad("via", 11),
pad("card", 6),
pad("covered", 9),
pad("arrival", 9),
pad("opaque", 8),
pad("blocked", 9),
pad("longest", 9),
pad("tasks", 7),
pad("frames", 8),
pad("blank", 7),
pad("live%", 7),
].join(" ");
console.log(`switch-cost — ${started}`);
console.log(` dist ${DIST}`);
console.log(` viewport ${VIEWPORT_NAME} ${VIEWPORT.width}x${VIEWPORT.height}`);
console.log(` backend ${backend}${software ? " (SOFTWARE — not comparable to the GPU baseline)" : ""}`);
console.log(` renderer ${renderer ?? "unknown"}`);
console.log(` takes ${REPEAT} per direction (median reported)`);
console.log("");
console.log(header);
console.log("-".repeat(header.length));
for (const row of rows) {
const live = row.coveredMs ? Math.round(((row.coveredMs - row.blockedMs) / row.coveredMs) * 100) : null;
console.log(
[
pad(row.direction, 22),
pad(row.via, 11),
pad(row.bootCard, 6),
pad(row.coveredMs, 9),
pad(row.arrivalMs, 9),
pad(row.opaqueMs, 8),
pad(row.blockedMs, 9),
pad(row.longestTaskMs, 9),
pad(row.longTaskCount, 7),
pad(row.frames, 8),
pad(row.blankFrames, 7),
pad(live === null ? "—" : `${live}%`, 7),
].join(" "),
);
}
console.log("");
console.log(
"live% is the share of the cover that is NOT blocked — a live, animating page with\n" +
"nothing to draw because the outgoing board was disposed. That is the part retention\n" +
"gets back for free. blank frames are frames that issued zero WebGL draw calls.",
);
if (rows.every((row) => row.longTaskCount === 0)) {
console.log("NOTE: no Long Tasks were observed at all — check longTaskSupported before believing it.");
}
for (const row of rows) {
for (const sample of row.samples) {
if (!sample.ok) console.log(`FAIL ${row.direction}: landed on ${sample.landedOn ?? "nothing"}`);
if (sample.consoleErrors.length > 0) {
console.log(`FAIL ${row.direction}: console errors ${JSON.stringify(sample.consoleErrors)}`);
}
}
}
if (JSON_OUT !== null) {
await writeFile(
JSON_OUT,
`${JSON.stringify({ started, dist: DIST, viewport: VIEWPORT_NAME, backend, renderer, software, repeat: REPEAT, rows }, null, 2)}\n`,
);
console.log(`wrote ${JSON_OUT}`);
}
process.exit(failed ? 1 : 0);