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:
@@ -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);
|
||||
Reference in New Issue
Block a user