From 37b4ecd88f36d5c4e44b6d20088c5906bbc5b1ae Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 08:54:25 +0300 Subject: [PATCH 1/3] [feat] 26-E: a scene-stress rig, and the meter finally counts a whole frame Roadmap 26 section 6. The section-2 budget numbers were estimates; this measures them. - tests/e2e/scene-stress.cjs: the manual rig (like net-stress.cjs). Per scene size: seed/import cost + long tasks, frame p50/p95/p99 idle AND orbiting, draw calls and triangles per display frame, geometries/textures, heap, object-list render ms, one autosave export (ms, bytes), optionally physics over the scene (bodies, step p50/p95, whether 26-G's stop fired) and a second peer joining (time-to-synced). Names the GPU and refuses to treat a software rasteriser as data. - tests/e2e/sceneStressProbe.cjs: the in-page probe both the rig and the suite drive, so the regression covers the real measurement code. Everything timed is timed in the page. - FOUND AND FIXED: the meter's triangles/calls read ONE fullscreen pass. renderer.info auto-resets per render() and a desktop frame is 13 render() calls, so 1,000 boxes read "1 call, 1 triangle": those budgets could never leave green and 26-G's sceneIsHeavy was asking about objects alone. sceneBudget now wraps the renderer instance's render and divides the sum by display frames. autoReset is untouched (VRStats, diagnostics and a reset-then-render test read what they always did) and it works in XR. Stopping the sampler hands back the original function. - FOUND AND FIXED: the loading stall timer (26-B M2) measured duration, not silence. A joiner receiving 3,000 boxes was still landing ~10/s when the bar cleared at 63s and a toast said 1,085 objects "never arrived"; all arrived by 180s. Every arrival re-arms it. - Metric sources the rig needed, registered from their own modules: bodies and physicsStepMs (physics.js), autosaveExportMs/autosaveBytes (autosave.js), syncMs and syncObjects (commandsHandler: announcement -> last object on the receiver's own clock; null for a batch closed unfinished). - BUDGETS retuned from the measurement (Radeon 890M, 1280x720): desktop calls [1000,2000] -> [2000,4500] (1,943 calls = 60fps; 4,446 = steady 30fps; 5,323 = p95 50ms) and triangles [1M,3M] -> [4M,8M] (6M/frame held 60fps). Required, not optional: the corrected counter reads ~2x objects (the shadow pass), so the old tiers would have made a 520-box scene "heavy" and armed 26-G's freeze streak in non-GPU suites. VR columns unchanged (owed on a headset). Full tables are in the lane handover. - Measured, not fixed here (handed to 26-D): ingest is FRAME-BOUND. 3,000 objects take ~180s to land while the joiner draws, 5.6s with drawing paused. Counterfactuals (each broken, suite red, restored): - countRenderCalls a no-op: 7 red (calls/triangles 0, not wrapped, doubling, stop/start) - 'bodies' source renamed: 4 red (no-sim reading, sim count, stopped run, rig row) - the sync `complete` flag forced true: 1 red (closed batch reports a fast sync) - 'autosaveExportMs' source renamed: 1 red - the stall re-arm removed: 1 red (batch given up on while still arriving) Suites: scene-stress (new) 28/28. Held, all green: scene-budget, overload-guard, ingest-gate, scene-poke, vr-stats, mesh-edit-materials, dispose, diagnostics, object-sync, net-handshake, physics-colliders, autosave-object-flows. svelte-check 352/47 (base 352/47). npm run build green. Co-Authored-By: Claude Opus 5 --- src/lib/autosave.js | 7 + src/lib/commandsHandler.svelte.js | 63 +++++- src/lib/physics.js | 30 +++ src/lib/sceneBudget.js | 104 ++++++++- tests/e2e/scene-stress.cjs | 256 ++++++++++++++++++++++ tests/e2e/scene-stress.test.cjs | 202 ++++++++++++++++++ tests/e2e/sceneStressProbe.cjs | 340 ++++++++++++++++++++++++++++++ 7 files changed, 997 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/scene-stress.cjs create mode 100644 tests/e2e/scene-stress.test.cjs create mode 100644 tests/e2e/sceneStressProbe.cjs diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 2b6f4848..ca7c37f4 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -32,6 +32,7 @@ import { log, registerDiagnosticsSection } from './diagnostics'; import { captureEditResume, applyEditResume } from './editResume'; import { disposeTree, keepSet } from './disposeTree'; import { safeStorage } from './safeStorage'; +import { registerMetricSource } from './sceneBudget'; // Crash safety: snapshots of the scene (GLTF json), the node graph and the // camera go to IndexedDB — debounced 30s after any change plus a 3-minute @@ -61,6 +62,12 @@ const MAX_DEBOUNCE_MS = 300_000; * debounceMs: number, lastSaveAt: number, writes: number, coalesced: number, * lastError: string | null}>} */ +// 26-E (roadmap 26 section 3): what the last snapshot cost, for the budget sampler and +// the stress rig. The status store already held both numbers; nothing sampled them. +// Registered, not imported by sceneBudget — that module stays a leaf. +registerMetricSource('autosaveExportMs', () => get(autosaveStatus).lastExportMs || null); +registerMetricSource('autosaveBytes', () => get(autosaveStatus).lastBytes || null); + export const autosaveStatus = writable({ lastExportMs: 0, lastBytes: 0, diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index a4d8a51c..8dba4266 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -417,7 +417,20 @@ let loadingStallTimer = null; * rejects. Nothing cleared it: the only writer was the Toasts effect, which removes a * uuid when its object APPEARS, and an object that never arrives never appears. */ const LOADING_STALL_MS = 60000; +let loadingStallMs = LOADING_STALL_MS; +/** TEST-ONLY: shorten the stall so "silence, not duration" is provable in seconds. + * @param {number} [ms] omit to restore the real value */ +export function setLoadingStallMsForTest(ms) { + loadingStallMs = Number.isFinite(ms) && /** @type {number} */ (ms) > 0 ? /** @type {number} */ (ms) : LOADING_STALL_MS; +} +// 26-E: THE STALL IS SILENCE, NOT DURATION. The timer was armed once, at the announcement, +// and never again — so any transfer that simply took longer than a minute was declared +// dead while it was still arriving. The stress rig measured exactly that: a joiner +// receiving 3,000 boxes on a real GPU was still landing ~10 objects a second at 63s when +// the bar cleared and the toast said "1085 objects never arrived"; all 3,000 arrived by +// 180s. Every uuid that lands now re-arms it (the `loading` subscription below), so the +// 60s is measured from the LAST sign of life, which is what M2 meant by a stall. function armLoadingStall() { clearTimeout(loadingStallTimer); loadingStallTimer = setTimeout(() => { @@ -426,11 +439,56 @@ function armLoadingStall() { console.log('Receiving objects: giving up on ' + left.length + ' that never arrived'); clearLoadingBatch(); showToast(left.length + ' object' + (left.length === 1 ? '' : 's') + ' never arrived.'); - }, LOADING_STALL_MS); + }, loadingStallMs); +} + +// 26-E (roadmap 26 section 3, "handshake time-to-synced"): how long the last RECEIVED +// batch took, from its `loading` announcement to the last object landing. The receive +// side is where the cost is felt, and it is the one moment both ends of the interval +// are known locally — no clock is compared across peers. LOCAL, never sent. +/** @type {number} */ +let loadingStartedAt = 0; +/** @type {number} */ +let loadingAnnounced = 0; +/** uuids still outstanding when the batch was CLOSED rather than finished (a stall, a + * departed sender, a cleared scene) — so a batch that never finished cannot report a + * sync time as though it had. */ +let loadingLeftAtClear = 0; +/** @type {{ms: number, objects: number, complete: boolean, at: number} | null} */ +let lastSync = null; +/** The last batch that ENDED (finished or closed), or null before one has run. */ +export function lastSyncStats() { + return lastSync; } +// Both ends of a batch pass through the store: the Toasts reconcile empties it as the +// last object appears, and `clearLoadingBatch` empties it on every other way out. +// Subscribing here sees both without touching either writer. +/** outstanding count at the last notification, so only PROGRESS re-arms the stall */ +let loadingLastLeft = 0; +loading.subscribe((/** @type {any} */ left) => { + const count = Array.isArray(left) ? left.length : 0; + // progress on an open batch: re-arm — but only a timer that is running, never one the + // ingest fork parked on purpose while its question is open + if (loadingStartedAt && count > 0 && count < loadingLastLeft && loadingStallTimer) armLoadingStall(); + loadingLastLeft = count; + if (!loadingStartedAt || count) return; + lastSync = { + ms: Math.round(performance.now() - loadingStartedAt), + objects: loadingAnnounced, + complete: loadingLeftAtClear === 0, + at: Date.now() + }; + loadingStartedAt = 0; + loadingLeftAtClear = 0; +}); +// only a batch that FINISHED has a sync time; a closed one says null rather than a +// number that would read as a fast join +registerMetricSource('syncMs', () => (lastSync?.complete ? lastSync.ms : null)); +registerMetricSource('syncObjects', () => (lastSync?.complete ? lastSync.objects : null)); /** Close the batch: the bar goes away, the stall timer disarms. Idempotent. */ export function clearLoadingBatch() { + if (loadingStartedAt) loadingLeftAtClear = /** @type {string[]} */ (get(loading)).length; clearTimeout(loadingStallTimer); loadingStallTimer = null; loadingSender = null; @@ -453,6 +511,9 @@ export async function createLoader(count, uuids, senderId) { loading.set(Array.isArray(uuids) ? uuids : []); loadingcount.set(count); loadingSender = senderId ?? null; + // an empty announcement opens nothing to finish, so it starts no clock + loadingStartedAt = Array.isArray(uuids) && uuids.length ? performance.now() : 0; + loadingAnnounced = Number(count) || 0; // 26-C: THE ONE MOMENT the size is known and nothing has been applied. Past it a // 4,000-object scene is simply happening to you. const verdict = ingestVerdict(liveObjectCount(), count, profileFor(get(globalRenderer))); diff --git a/src/lib/physics.js b/src/lib/physics.js index cc27ca41..9ef86c34 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -1,6 +1,7 @@ import * as THREE from 'three'; // 26-G: the streak watch is a pure leaf (stores + sceneBudget) — no edge into history. import { createStreakWatch, PHYSICS_SLOW_MS, PHYSICS_SLOW_STEPS } from './overloadGuard'; +import { registerMetricSource } from './sceneBudget'; import { writable, get } from 'svelte/store'; import { flowGraphs, allNodes, allEdges, SCENE_GRAPH } from '../stores/flowStore'; import { objectsGroup, lockedObjects, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore'; @@ -826,6 +827,7 @@ async function startSimulation() { // ColliderDesc.trimesh (fixed bodies only) and terrain from a heightfield — // both deferred; every collider today is a cuboid AABB or an opt-in hull. bodies = []; + stepTimes = []; // 26-E: a new run's cost is not the last run's beforeStates = []; suspendedForRun = []; fixedBodies = new Map(); @@ -1255,6 +1257,7 @@ function step(now) { try { const started = performance.now(); stepInner(now); + noteStepMs(performance.now() - started); // 26-G (roadmap 26 Stage 3): A SIMULATION THAT CANNOT KEEP UP. 27-C catches a step // that THROWS; nothing caught one that simply takes longer than the frame it runs // in, which turns every frame late before rendering starts and reads as the app @@ -1277,6 +1280,33 @@ function step(now) { const slowStepWatch = createStreakWatch({ overMs: PHYSICS_SLOW_MS, count: PHYSICS_SLOW_STEPS }); +// 26-E: what a simulation COSTS, for the budget sampler and the stress rig. Roadmap 26 +// section 2 budgets dynamic bodies (<200 desktop) and section 3 names the step time; +// neither was readable anywhere. Registered, never imported — sceneBudget is a leaf and +// physics sits in the history family. A step ring rather than the last value, because +// the question is the same as for frames: the step you FEEL is the slow one. +const STEP_RING = 120; +/** @type {number[]} */ +let stepTimes = []; +/** @param {number} ms */ +function noteStepMs(ms) { + stepTimes.push(ms); + if (stepTimes.length > STEP_RING) stepTimes.shift(); +} +/** p95 of the recent steps, or null when no simulation is running (a stale ring from a + * run that ended must not read as a live cost). */ +export function physicsStepStats() { + if (!world || !stepTimes.length) return null; + const sorted = [...stepTimes].sort((a, b) => a - b); + const at = (/** @type {number} */ q) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))]; + return { n: sorted.length, p50: at(0.5), p95: at(0.95), max: sorted[sorted.length - 1] }; +} +registerMetricSource('bodies', () => (world ? bodies.length : 0)); +registerMetricSource('physicsStepMs', () => { + const stats = physicsStepStats(); + return stats ? Math.round(stats.p95 * 100) / 100 : null; +}); + /** ONE stop path for the slow-step streak, shared by the real step and the test hook so * the two cannot drift apart. */ function stopForSlowSteps() { diff --git a/src/lib/sceneBudget.js b/src/lib/sceneBudget.js index cc446420..0ee6083d 100644 --- a/src/lib/sceneBudget.js +++ b/src/lib/sceneBudget.js @@ -46,11 +46,18 @@ export const BUDGETS = [ vr: [500, 1500], why: 'every object is at least one draw call, one wire message per joiner, one row in the tree and one node in every traversal' }, + // 26-E MEASURED the two render axes below (tests/e2e/scene-stress.cjs, Radeon 890M + // iGPU, 1280x720): they are counted per DISPLAY frame across every render() call now, + // which the starting numbers never were — a frame is ~13 calls with the composer, and + // the shadow pass draws each mesh again, so both read about TWICE the naive count. + // VR/mobile columns are still the starting estimates; they are owed on a headset. { key: 'triangles', label: 'Triangles / frame', unit: '', - desktop: [1000000, 3000000], + // 6.0M/frame (15 x 200k-tri models, shadow pass included) held a locked 60fps on + // an integrated GPU; the red edge above that is extrapolated, not measured + desktop: [4000000, 8000000], vr: [300000, 600000], why: 'vertex and fill cost, at 60Hz on a desktop against 72-90Hz on a headset' }, @@ -58,7 +65,10 @@ export const BUDGETS = [ key: 'calls', label: 'Draw calls / frame', unit: '', - desktop: [1000, 2000], + // measured: 1,943 calls (1,000 boxes) 60fps p95 16.7 · 2,799-3,625 p95 33 · + // 4,446 a steady 30fps · 5,323 p95 50 · 16,342 p95 133. Calls, not triangles, are + // what binds a many-object scene: it is CPU time per call + desktop: [2000, 4500], vr: [300, 500], why: 'there is no instancing or batching in core, so every call is CPU time' }, @@ -377,10 +387,89 @@ function walkScene() { return { objects, meshes, hidden }; } +// --- per-frame render totals (26-E) ----------------------------------------------- +// +// THE FINDING the stress rig made on its first run: 1,000 boxes on screen, and the meter +// read `triangles: 1, calls: 1`. `renderer.info` is AUTO-RESET at the start of every +// `renderer.render()` call, and a desktop frame is not one call — the EffectComposer +// renders the scene into a target, then N8AO, then the outline, then a fullscreen +// triangle to the canvas, each its own `render()`. Whatever reads `info` afterwards sees +// the LAST pass: one triangle, one call. So the triangle and draw-call budgets could +// never leave green, and 26-G's `sceneIsHeavy` was really asking about objects alone. +// +// The fix counts EVERY `render()` and divides by the display frames the sampler saw. +// Deliberately NOT `info.autoReset = false`: that changes what `info` means for every +// other reader (the VR stats plate, the diagnostics section, a test that resets and +// renders once), and inside a WebXR session `window.requestAnimationFrame` does not run, +// so nothing would ever reset it again and the plate would count up forever. A wrapper +// on the instance leaves `info` byte-identical for everyone and works in XR too. + +const renderAcc = { calls: 0, triangles: 0, renders: 0 }; +/** Display frames the sampler loop counted since the last sample. */ +let renderFrames = 0; + +/** + * Wrap this renderer's `render` so each call adds what it drew to the accumulator. Once + * per instance (a restored context can hand the store a NEW renderer, which gets its own). + * @param {any} renderer + */ +export function countRenderCalls(renderer) { + if (!renderer || typeof renderer.render !== 'function' || renderer.__budgetRender) return false; + const original = renderer.render; + renderer.__budgetRender = original; + renderer.render = function (/** @type {any[]} */ ...args) { + const info = this.info?.render; + // with autoReset ON (three's default) render() zeroes the counters itself, so the + // base is 0; with it OFF somebody is accumulating on purpose and we take the delta + const baseCalls = info && this.info.autoReset === false ? info.calls : 0; + const baseTris = info && this.info.autoReset === false ? info.triangles : 0; + const result = original.apply(this, args); + if (info) { + renderAcc.calls += info.calls - baseCalls; + renderAcc.triangles += info.triangles - baseTris; + renderAcc.renders++; + } + return result; + }; + return true; +} + +/** Undo `countRenderCalls` — the sampler stopping must leave the renderer as it found it. + * @param {any} renderer */ +export function uncountRenderCalls(renderer) { + if (!renderer?.__budgetRender) return; + renderer.render = renderer.__budgetRender; + delete renderer.__budgetRender; +} + +/** @type {{calls: number, triangles: number, rendersPerFrame: number} | null} */ +let lastTotals = null; + +/** Per display frame since the last call, then start a new window. A window with no + * frame in it (two forced readings back to back, a paused loop) keeps the previous + * reading rather than inventing a zero — "nothing measured" is not "nothing drawn". */ +function takeRenderTotals() { + const frames = renderFrames; + if (frames === 0) return lastTotals; + const out = { + calls: Math.round(renderAcc.calls / frames), + triangles: Math.round(renderAcc.triangles / frames), + rendersPerFrame: Math.round((renderAcc.renders / frames) * 10) / 10 + }; + lastTotals = out; + renderAcc.calls = 0; + renderAcc.triangles = 0; + renderAcc.renders = 0; + renderFrames = 0; + return out; +} + function sample() { /** @type {any} */ const renderer = get(globalRenderer); + if (running) countRenderCalls(renderer); const info = renderer?.info; + const totals = takeRenderTotals(); const profile = profileFor(renderer); const scene = walkScene(); const fps = frameStats(); @@ -403,8 +492,11 @@ function sample() { objects: scene.objects, meshes: scene.meshes, hidden: scene.hidden, - triangles: info?.render?.triangles ?? null, - calls: info?.render?.calls ?? null, + // per DISPLAY frame across every render() call; before the sampler has counted a + // frame (a forced reading straight after boot) fall back to the raw last pass + triangles: totals ? totals.triangles : (info?.render?.triangles ?? null), + calls: totals ? totals.calls : (info?.render?.calls ?? null), + rendersPerFrame: totals ? totals.rendersPerFrame : null, geometries: info?.memory?.geometries ?? null, textures: info?.memory?.textures ?? null, frameP50: fps.p50, @@ -449,6 +541,7 @@ function loop() { } } lastFrameAt = now; + renderFrames++; if (now - lastSampleAt >= SAMPLE_MS) { lastSampleAt = now; sample(); @@ -467,6 +560,8 @@ export function startSceneMetrics() { running = true; lastFrameAt = 0; lastSampleAt = 0; + renderFrames = 0; + countRenderCalls(get(globalRenderer)); startLongTasks(); rafId = requestAnimationFrame(loop); } @@ -476,6 +571,7 @@ export function stopSceneMetrics() { if (rafId != null) cancelAnimationFrame(rafId); rafId = null; stopLongTasks(); + uncountRenderCalls(get(globalRenderer)); } /** Force a reading now — the overlay opening, and the suite. */ diff --git a/tests/e2e/scene-stress.cjs b/tests/e2e/scene-stress.cjs new file mode 100644 index 00000000..d4cb0ca5 --- /dev/null +++ b/tests/e2e/scene-stress.cjs @@ -0,0 +1,256 @@ +// 26-E — THE SCENE-STRESS RIG (roadmap 26 section 6). A MEASUREMENT, run by hand. +// +// APP_URL=https://theprototype.app:5180/ node tests/e2e/scene-stress.cjs \ +// [--sizes 100,1000,3000,10000] [--dense 1,5,15] [--dense-tris 200000] \ +// [--physics 100,300,1000] [--sync 1000,3000] [--window 4000] \ +// [--view shaded-ao|shaded] [--out path.md] +// +// NOT a .test.cjs on purpose: a full sweep runs for many minutes. `npm run e2e -- +// scene-stress` runs the small REGRESSION suite instead (scene-stress.test.cjs), which +// drives the same probe (sceneStressProbe.cjs) at a tiny size. +// +// WHY IT EXISTS: roadmap 26 section 2's budget numbers were starting points reasoned from +// WebGL practice. The governor (26-D) and the auto-stops (26-G) steer by them, so they +// have to be MEASURED. Per scene size this records: +// - seed / import cost and the long tasks it caused +// - frame p50/p95/p99 idle and while ORBITING (navigation is when a heavy scene hurts) +// - draw calls and triangles per DISPLAY frame (see sceneBudget's render-totals note — +// the raw `renderer.info` reads one fullscreen pass and cannot be used) +// - GPU proxies (geometries/textures) and the JS heap +// - object-list render ms (and whether 26-B windowed it) +// - one autosave export: ms and bytes +// - optionally, physics over the same scene: body count, step p50/p95, whether 26-G's +// slow-step stop fired +// - optionally, a second peer JOINING: time-to-synced as the joiner's own +// `syncMs` reads it (announcement -> last object landed), plus the joiner's long tasks +// +// CAVEATS worth printing with every table: +// - the numbers belong to ONE GPU; the report names it (WEBGL_debug_renderer_info). A +// SwiftShader row (no GPU) measures the CPU rasteriser, not the app — the rig refuses +// to treat one as data and says so. +// - headless Chromium has no compositor pressure from other windows; a real desktop is +// worse, never better. +// - the two-peer sync uses whatever signaling the helpers use (PEER_CONFIG). It is ONE +// joiner and one handshake — not a flood. + +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); +const { measureScene, installProbe, summarize } = require('./sceneStressProbe.cjs'); + +const argv = process.argv.slice(2); +/** @param {string} name @param {string} fallback */ +function arg(name, fallback) { + const i = argv.indexOf('--' + name); + return i >= 0 && argv[i + 1] != null ? argv[i + 1] : fallback; +} +/** @param {string} value */ +const list = (value) => + value + .split(',') + .map((n) => parseInt(n, 10)) + .filter((n) => Number.isFinite(n) && n > 0); + +const SIZES = list(arg('sizes', '100,1000,3000,10000')); +const DENSE = list(arg('dense', '1,5,15')); +const DENSE_TRIS = parseInt(arg('dense-tris', '200000'), 10); +const PHYSICS = list(arg('physics', '')); +const SYNC = list(arg('sync', '')); +const WINDOW_MS = parseInt(arg('window', '4000'), 10); +const VIEW = arg('view', ''); +const OUT = arg('out', ''); +const storage = VIEW ? { viewMode: VIEW } : undefined; + +/** @param {any} x @param {number} [d] */ +const r = (x, d = 1) => (x == null || !Number.isFinite(Number(x)) ? '—' : Number(Number(x).toFixed(d))); + +/** + * A second peer joins a host already holding `size` boxes. The joiner's own `syncMs` + * metric (commandsHandler, 26-E) is the answer: announcement to last object, measured on + * one clock. + * @param {any} browser @param {number} size + */ +async function measureSync(browser, size) { + const host = await h.setupPage(browser, 'host-' + size, { storage }); + const joiner = await h.setupPage(browser, 'join-' + size, { storage }); + try { + await installProbe(host.page); + await installProbe(joiner.page); + await host.page.evaluate((n) => window.__stress.seedCubes(n), size); + const t0 = Date.now(); + await joiner.page.evaluate(() => (window.__stress.joinStarted = performance.now())); + await h.connect(joiner, host, 0); + // wait for the joiner to hold the scene AND for its batch to have closed + const deadline = Date.now() + 240000; + /** @type {any} */ + let got = null; + while (Date.now() < deadline) { + got = await joiner.page.evaluate(() => ({ + count: window.__stress.count(), + sync: window.__stores.commandsHandler.lastSyncStats(), + tasks: window.__stress.tasksSince(window.__stress.joinStarted) + })); + if (got.sync && got.count >= size) break; + await joiner.page.waitForTimeout(250); + } + return { + size, + wallMs: Date.now() - t0, + objects: got?.count ?? 0, + syncMs: got?.sync?.complete ? got.sync.ms : null, + complete: !!got?.sync?.complete, + joinerLongTasks: got?.tasks?.count ?? null, + joinerLongestTask: got?.tasks ? Math.round(got.tasks.longest) : null, + joinerBusyMs: got?.tasks ? Math.round(got.tasks.busy) : null + }; + } finally { + await host.ctx.close(); + await joiner.ctx.close(); + } +} + +/** @param {any[]} rows @param {any[]} dense @param {any[]} physics @param {any[]} sync */ +function report(rows, dense, physics, sync) { + const gpu = rows[0]?.gpu ?? dense[0]?.gpu ?? physics[0]?.gpu ?? 'unknown'; + const L = []; + L.push('# 26-E — scene stress, measured'); + L.push(''); + L.push('GPU: `' + gpu + '` · window ' + WINDOW_MS + 'ms per reading · 1280x720 · view ' + (VIEW || 'default')); + if (/swiftshader|llvmpipe|software/i.test(gpu)) + L.push('\n**WARNING: software rasteriser — these rows measure the CPU renderer, not the app. Do not fold them into the budget.**'); + L.push(''); + L.push('## Boxes (the real `/create box` path)'); + L.push(''); + L.push('| objects | seed ms | seed longest task | idle p50/p95/p99 | orbit p50/p95/p99 | orbit long tasks | calls/frame | tris/frame | renders/frame | geoms | textures | heap MB | list ms (rows, mode) | autosave ms / MB |'); + L.push('|---|---|---|---|---|---|---|---|---|---|---|---|---|---|'); + for (const w of rows) { + L.push( + '| ' + w.objects + + ' | ' + r(w.seedMs, 0) + + ' | ' + r(w.seedLongestTask, 0) + + ' | ' + r(w.idle.p50) + ' / ' + r(w.idle.p95) + ' / ' + r(w.idle.p99) + + ' | ' + r(w.orbit.p50) + ' / ' + r(w.orbit.p95) + ' / ' + r(w.orbit.p99) + + ' | ' + w.orbitLongTasks + ' (max ' + r(w.orbitLongestTask, 0) + ')' + + ' | ' + r(w.calls, 0) + + ' | ' + r(w.triangles, 0) + + ' | ' + r(w.rendersPerFrame) + + ' | ' + r(w.geometries, 0) + + ' | ' + r(w.textures, 0) + + ' | ' + r(w.heapMB, 0) + + ' | ' + r(w.listMs, 0) + ' (' + w.listRows + ', ' + w.listMode + ')' + + ' | ' + r(w.autosaveExportMs, 0) + ' / ' + r((w.autosaveBytes ?? 0) / 1048576, 2) + + (w.autosaveError ? ' ERR' : '') + + ' |' + ); + } + if (dense.length) { + L.push(''); + L.push('## Dense models (a ' + DENSE_TRIS + '-triangle GLB through the real import path)'); + L.push(''); + L.push('| models | import p50/max ms | import longest task | idle p50/p95/p99 | orbit p50/p95/p99 | tris/frame | calls/frame | heap MB | autosave ms / MB |'); + L.push('|---|---|---|---|---|---|---|---|---|'); + for (const w of dense) { + L.push( + '| ' + w.objects + + ' | ' + r(w.importMsP50, 0) + ' / ' + r(w.importMsMax, 0) + + ' | ' + r(w.importLongestTask, 0) + + ' | ' + r(w.idle.p50) + ' / ' + r(w.idle.p95) + ' / ' + r(w.idle.p99) + + ' | ' + r(w.orbit.p50) + ' / ' + r(w.orbit.p95) + ' / ' + r(w.orbit.p99) + + ' | ' + r(w.triangles, 0) + + ' | ' + r(w.calls, 0) + + ' | ' + r(w.heapMB, 0) + + ' | ' + r(w.autosaveExportMs, 0) + ' / ' + r((w.autosaveBytes ?? 0) / 1048576, 2) + + ' |' + ); + } + } + if (physics.length) { + L.push(''); + L.push('## Physics over N dynamic boxes (26-G stops a run at ' + '30 steps over 24ms)'); + L.push(''); + L.push('| boxes | bodies | step p50 / p95 ms | frame p50/p95 while simulating | auto-stopped |'); + L.push('|---|---|---|---|---|'); + for (const w of physics) { + L.push( + '| ' + w.size + + ' | ' + (w.physicsAutoStopped ? 'stopped' : r(w.bodies, 0)) + + ' | ' + r(w.stepP50) + ' / ' + r(w.stepP95) + + ' | ' + (w.physicsFrame ? r(w.physicsFrame.p50) + ' / ' + r(w.physicsFrame.p95) : '—') + + ' | ' + (w.physicsStarted ? (w.physicsAutoStopped ? 'YES' : 'no') : 'did not start') + + ' |' + ); + } + } + if (sync.length) { + L.push(''); + L.push('## A joiner receiving the scene (two peers, one handshake)'); + L.push(''); + L.push('| objects | joiner syncMs | wall ms (dial -> synced) | joiner long tasks | longest | busy ms |'); + L.push('|---|---|---|---|---|---|'); + for (const w of sync) { + L.push( + '| ' + w.objects + '/' + w.size + + ' | ' + (w.complete ? r(w.syncMs, 0) : 'INCOMPLETE') + + ' | ' + r(w.wallMs, 0) + + ' | ' + r(w.joinerLongTasks, 0) + + ' | ' + r(w.joinerLongestTask, 0) + + ' | ' + r(w.joinerBusyMs, 0) + + ' |' + ); + } + } + L.push(''); + L.push('```json'); + L.push(JSON.stringify({ rows, dense, physics, sync }, null, 1)); + L.push('```'); + return L.join('\n'); +} + +(async () => { + // precise-memory: without it performance.memory is bucketed and every size reads the same heap + const browser = await h.launch({ args: [...h.GPU_ARGS, '--enable-precise-memory-info'] }); + const rows = []; + const dense = []; + const physics = []; + const sync = []; + try { + for (const size of SIZES) { + console.log('\n==== ' + size + ' boxes ===='); + const row = await measureScene(h, browser, { kind: 'cubes', size, windowMs: WINDOW_MS, storage }); + console.log(JSON.stringify({ ...row, gpu: undefined })); + rows.push(row); + } + for (const size of DENSE) { + console.log('\n==== ' + size + ' dense models ===='); + const row = await measureScene(h, browser, { kind: 'dense', size, windowMs: WINDOW_MS, denseTris: DENSE_TRIS, storage }); + console.log(JSON.stringify({ ...row, gpu: undefined })); + dense.push(row); + } + for (const size of PHYSICS) { + console.log('\n==== physics over ' + size + ' boxes ===='); + const row = await measureScene(h, browser, { kind: 'cubes', size, windowMs: WINDOW_MS, physics: true, autosave: false, storage }); + console.log(JSON.stringify({ bodies: row.bodies, stepP50: row.stepP50, stepP95: row.stepP95, stopped: row.physicsAutoStopped, frame: row.physicsFrame })); + physics.push(row); + } + for (const size of SYNC) { + console.log('\n==== a joiner receiving ' + size + ' boxes ===='); + const row = await measureSync(browser, size); + console.log(JSON.stringify(row)); + sync.push(row); + } + const md = report(rows, dense, physics, sync); + console.log('\n' + md.split('```json')[0]); + if (OUT) { + const out = path.isAbsolute(OUT) ? OUT : path.resolve(process.cwd(), OUT); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, md); + console.log('written to ' + out); + } + } catch (err) { + console.error('STRESS RUN FAILED:', err && err.stack ? err.stack : err); + process.exitCode = 1; + } finally { + await browser.close(); + } + void summarize; +})(); diff --git a/tests/e2e/scene-stress.test.cjs b/tests/e2e/scene-stress.test.cjs new file mode 100644 index 00000000..34df9e67 --- /dev/null +++ b/tests/e2e/scene-stress.test.cjs @@ -0,0 +1,202 @@ +// 26-E — the scene-stress rig's REGRESSION suite (roadmap 26 section 6). +// +// `scene-stress.cjs` is the measurement rig, run by hand; this proves the machinery it +// stands on still measures what it says: +// 1. the percentile rule the rig reports is the meter's rule (pure, no browser) +// 2. THE FINDING: draw calls and triangles are counted per DISPLAY frame across every +// `renderer.render()` — the raw `renderer.info` reads one fullscreen pass (1 call, +// 1 triangle with 150 boxes on screen), so the triangle and draw-call budgets could +// never leave green +// 3. stopping the sampler hands the renderer back unwrapped, and starting re-wraps it +// 4. the metric sources the rig needed are registered from their own modules: physics +// bodies + step time, autosave export ms/bytes, and the receive-side sync time +// 4b. a loading stall is measured from the last ARRIVAL, not the announcement (the rig +// found a 3,000-object join declared dead at 63s while still landing) +// 5. the rig's per-size runner produces a COMPLETE row end to end at a tiny size, so +// the manual rig cannot rot unnoticed between the runs that feed the roadmap +// +// Run: APP_URL=https://theprototype.app:5180/ npm run e2e -- scene-stress +const h = require('./helpers.cjs'); +const { percentile, summarize, installProbe, measureScene } = require('./sceneStressProbe.cjs'); + +h.run(async () => { + // ---- 1. the pure part -------------------------------------------------------------- + const ring = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + h.check(percentile(ring, 0.5) === 50 && percentile(ring, 0.95) === 100, `nearest-rank percentiles (p50 ${percentile(ring, 0.5)}, p95 ${percentile(ring, 0.95)})`); + const s = summarize([5, 1, NaN, 3]); + h.check(s.n === 3 && s.p50 === 3 && s.max === 5, `summarize sorts, drops non-numbers and reports max (${JSON.stringify(s)})`); + h.check(summarize([]).p95 === null, 'an empty window reports null, never a zero that reads as a fast frame'); + + // GPU args: frame-time and per-frame render totals over real frames (the e2e skill's rule) + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const gpu = await installProbe(A.page); + console.log('renderer: ' + gpu); + + // the meter's own rule, on the same numbers, in the page + const meterRule = await A.page.evaluate((values) => { + const b = window.__stores.sceneBudget; + for (let i = 0; i < 300; i++) b.noteFrame(1000); // push the ring out of the way + for (const v of values) for (let i = 0; i < 24; i++) b.noteFrame(v); + return b.frameStats(); + }, ring); + h.check( + meterRule.p50 === percentile([...ring.flatMap((v) => Array(24).fill(v))], 0.5) && + meterRule.p95 === percentile([...ring.flatMap((v) => Array(24).fill(v))], 0.95), + `the rig's percentile is the meter's percentile (meter p50 ${meterRule.p50} p95 ${meterRule.p95})` + ); + + // ---- 2. per-frame render totals ---------------------------------------------------- + const seeded = await A.page.evaluate(() => window.__stress.seedCubes(150)); + h.check(seeded.count === 150, `premise: 150 real boxes in the scene (${seeded.count})`); + await A.page.evaluate(() => window.__stress.frameAll(150)); + const totals = await A.page.evaluate(async () => { + const { sceneBudget, globalRenderer } = window.__stores; + let r; + globalRenderer.subscribe((/** @type {any} */ v) => (r = v))(); + await new Promise((res) => setTimeout(res, 1200)); + const m = sceneBudget.sampleSceneMetrics(); + // what one reader of renderer.info sees at an arbitrary moment: the last pass + const lastPass = { calls: r.info.render.calls, triangles: r.info.render.triangles }; + return { calls: m.calls, triangles: m.triangles, rendersPerFrame: m.rendersPerFrame, lastPass, wrapped: !!r.__budgetRender }; + }); + h.check(totals.wrapped, 'the sampler wraps the live renderer'); + h.check( + totals.rendersPerFrame > 1, + `premise: a desktop frame is SEVERAL render() calls, not one (${totals.rendersPerFrame} per frame)` + ); + h.check( + totals.lastPass.calls < 150, + `premise: raw renderer.info reads only the last pass (${totals.lastPass.calls} calls, ${totals.lastPass.triangles} triangles)` + ); + h.check(totals.calls >= 150, `draw calls per frame count every box (${totals.calls} for 150 boxes)`); + h.check(totals.triangles >= 150 * 12, `triangles per frame count every box (${totals.triangles} >= ${150 * 12})`); + + // more objects must move the reading — the axis is live, not a constant + await A.page.evaluate(() => window.__stress.seedCubes(150)); + await A.page.evaluate(() => window.__stress.frameAll(300)); + const doubled = await A.page.evaluate(async () => { + await new Promise((res) => setTimeout(res, 1200)); + return window.__stores.sceneBudget.sampleSceneMetrics(); + }); + h.check( + doubled.calls > totals.calls * 1.5 && doubled.triangles > totals.triangles * 1.5, + `doubling the boxes roughly doubles the reading (calls ${totals.calls} -> ${doubled.calls}, tris ${totals.triangles} -> ${doubled.triangles})` + ); + + // ---- 3. stop hands the renderer back, start re-wraps ------------------------------ + const cycle = await A.page.evaluate(async () => { + const { sceneBudget, globalRenderer } = window.__stores; + let r; + globalRenderer.subscribe((/** @type {any} */ v) => (r = v))(); + // three defines `render` as an OWN property in its constructor, so "restored" means the + // very same function object is back, not that the property is gone + const original = r.__budgetRender; + sceneBudget.stopSceneMetrics(); + const stopped = { wrapped: !!r.__budgetRender, same: !!original && r.render === original }; + sceneBudget.startSceneMetrics(); + await new Promise((res) => setTimeout(res, 800)); + return { stopped, restarted: !!r.__budgetRender }; + }); + h.check(!cycle.stopped.wrapped && cycle.stopped.same, `stopping the sampler restores the renderer's own render (${JSON.stringify(cycle.stopped)})`); + h.check(cycle.restarted, 'starting it again re-wraps the renderer'); + + // ---- 4. the metric sources --------------------------------------------------------- + const save = await A.page.evaluate(async () => { + await window.__stores.autosave.saveNow(); + const m = window.__stores.sceneBudget.sampleSceneMetrics(); + return { exportMs: m.autosaveExportMs, bytes: m.autosaveBytes }; + }); + h.check(save.exportMs > 0 && save.bytes > 1000, `autosave export ms and bytes reach the sampler (${save.exportMs}ms, ${save.bytes} bytes)`); + + const phys = await A.page.evaluate(async () => { + const { physics, sceneBudget } = window.__stores; + const before = sceneBudget.sampleSceneMetrics(); + const run = await window.__stress.physics(1500); + const after = sceneBudget.sampleSceneMetrics(); + return { beforeBodies: before.bodies, beforeStep: before.physicsStepMs, run, afterBodies: after.bodies, afterStep: after.physicsStepMs, stats: physics.physicsStepStats() }; + }); + h.check(phys.beforeBodies === 0 && phys.beforeStep === null, `no simulation: 0 bodies and no step time (${phys.beforeBodies}, ${phys.beforeStep})`); + h.check(phys.run.startedOk, 'premise: the simulation started'); + h.check(phys.run.bodies === 300, `while simulating the sampler counts the bodies (${phys.run.bodies} for 300 dynamic boxes)`); + h.check(phys.run.step && phys.run.step.n > 20 && phys.run.step.p95 > 0, `…and the step time (${JSON.stringify(phys.run.step)})`); + h.check( + phys.afterBodies === 0 && phys.afterStep === null && phys.stats === null, + `a stopped run reads as no run, never a stale cost (${phys.afterBodies}, ${phys.afterStep})` + ); + + const sync = await A.page.evaluate(async () => { + const { commandsHandler, sceneBudget } = window.__stores; + // a batch that FINISHES: every announced uuid counted as arrived + await commandsHandler.createLoader(2, ['stress-a', 'stress-b'], 'nobody'); + await new Promise((res) => setTimeout(res, 300)); + commandsHandler.noteLoadFailed(['stress-a', 'stress-b']); + const finished = { stats: commandsHandler.lastSyncStats(), metric: sceneBudget.sampleSceneMetrics().syncMs }; + // a batch that is CLOSED before it finishes (the sender left, a scene clear) + await commandsHandler.createLoader(2, ['stress-c', 'stress-d'], 'nobody'); + await new Promise((res) => setTimeout(res, 100)); + commandsHandler.clearLoadingBatch(); + const closed = { stats: commandsHandler.lastSyncStats(), metric: sceneBudget.sampleSceneMetrics().syncMs }; + return { finished, closed }; + }); + h.check( + sync.finished.stats?.complete === true && sync.finished.metric >= 280 && sync.finished.stats.objects === 2, + `a finished batch reports its sync time, on one clock (${JSON.stringify(sync.finished)})` + ); + h.check( + sync.closed.stats?.complete === false && sync.closed.metric === null, + `a batch closed before it finished reports NO sync time, not a fast one (${JSON.stringify(sync.closed)})` + ); + // ---- 4b. a stall is SILENCE, not duration ------------------------------------------ + // The rig's finding: the 60s stall timer was armed once at the announcement, so a + // 3,000-object join still landing ~10 objects a second was declared dead at 63s. Here + // the stall is 800ms and the batch keeps making progress past it. + const stall = await A.page.evaluate(async () => { + const { commandsHandler, loading } = window.__stores; + const read = () => { + let v; + loading.subscribe((/** @type {any} */ x) => (v = x))(); + return v.length; + }; + const sleep = (/** @type {number} */ ms) => new Promise((r) => setTimeout(r, ms)); + commandsHandler.setLoadingStallMsForTest(800); + try { + await commandsHandler.createLoader(4, ['st-1', 'st-2', 'st-3', 'st-4'], 'nobody'); + const trace = []; + // one arrival every 500ms: total 1.5s, never 800ms of silence + for (const uuid of ['st-1', 'st-2', 'st-3']) { + await sleep(500); + commandsHandler.noteLoadFailed([uuid]); + trace.push(read()); + } + const aliveAfter1500 = read(); + // …then silence: the stall must still fire + await sleep(1300); + return { trace, aliveAfter1500, afterSilence: read(), sync: commandsHandler.lastSyncStats() }; + } finally { + commandsHandler.setLoadingStallMsForTest(); + } + }); + h.check( + stall.aliveAfter1500 === 1, + `a batch still arriving past the stall window is NOT given up on (${JSON.stringify(stall.trace)} left after 1.5s)` + ); + h.check( + stall.afterSilence === 0 && stall.sync?.complete === false, + `…and real silence still ends it, reported as incomplete (${stall.afterSilence} left, ${JSON.stringify(stall.sync)})` + ); + + h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`); + await A.ctx.close(); + + // ---- 5. the rig's runner, end to end, at a tiny size ------------------------------- + const row = await measureScene(h, browser, { kind: 'cubes', size: 40, windowMs: 1000, physics: true }); + const numeric = ['seedMs', 'objects', 'triangles', 'calls', 'rendersPerFrame', 'geometries', 'textures', 'listMs', 'autosaveExportMs', 'autosaveBytes', 'bodies', 'stepP95']; + const missing = numeric.filter((key) => !Number.isFinite(row[key])); + h.check(missing.length === 0, `the rig produces a complete row (missing: ${JSON.stringify(missing)})`); + h.check(row.idle.n > 20 && row.orbit.n > 20 && row.idle.p95 > 0, `…with real frame windows (idle ${row.idle.n} frames, orbit ${row.orbit.n})`); + h.check(row.objects === 40 && row.listRows === 40 && row.bodies === 40, `…measuring the scene it built (${row.objects} objects, ${row.listRows} rows, ${row.bodies} bodies)`); + h.check(row.pageErrors === 0, `…with no page errors (${row.pageErrors})`); + + await h.finish(browser); +}); diff --git a/tests/e2e/sceneStressProbe.cjs b/tests/e2e/sceneStressProbe.cjs new file mode 100644 index 00000000..569da473 --- /dev/null +++ b/tests/e2e/sceneStressProbe.cjs @@ -0,0 +1,340 @@ +// 26-E — THE SCENE-STRESS PROBE, shared by the manual rig and its regression suite. +// +// `scene-stress.cjs` is the measurement rig (a many-minute sweep, run by hand, like +// `net-stress.cjs`). `scene-stress.test.cjs` is the quick regression that proves the rig +// still measures what it says it measures. Both drive THIS file, so the suite covers the +// real measurement code rather than a copy of it that can drift. +// +// Everything that is timed is timed INSIDE the page. A CDP round trip is several +// milliseconds on this box, which is a third of a frame — a frame time measured across +// the bridge is a measurement of the bridge. +// +// Not a `.test.cjs`, so the runner never picks it up on its own. + +/** + * Nearest-rank percentile — the SAME rule `sceneBudget.frameStats` uses, so a number the + * rig reports and a number the meter reports mean the same thing. PURE. + * @param {number[]} sorted ascending @param {number} q 0..1 + */ +function percentile(sorted, q) { + if (!sorted.length) return null; + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1)); + return sorted[index]; +} + +/** @param {number[]} values */ +function summarize(values) { + const sorted = [...values].filter(Number.isFinite).sort((a, b) => a - b); + return { + n: sorted.length, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + p99: percentile(sorted, 0.99), + max: sorted.length ? sorted[sorted.length - 1] : null + }; +} + +/** + * Install `window.__stress` in the page. Idempotent. Returns the renderer string, so a + * report can say what GPU its numbers came from (they are meaningless without it). + * @param {any} page + */ +async function installProbe(page) { + return page.evaluate(() => { + /** @type {any} */ + const w = window; + const s = w.__stores; + /** @param {any} store */ + const read = (store) => { + let v; + store.subscribe((/** @type {any} */ x) => (v = x))(); + return v; + }; + /** @param {number} ms */ + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const nextFrame = () => new Promise((r) => requestAnimationFrame(r)); + + if (!w.__stress) { + /** @type {any} */ + const ns = (w.__stress = { tasks: [] }); + try { + ns.observer = new PerformanceObserver((list) => { + for (const e of list.getEntries()) ns.tasks.push({ at: e.startTime, ms: e.duration }); + }); + ns.observer.observe({ entryTypes: ['longtask'] }); + ns.longTasksAvailable = true; + } catch { + ns.longTasksAvailable = false; + } + + /** Long tasks that STARTED inside [from, now]. */ + ns.tasksSince = (/** @type {number} */ from) => { + const hit = ns.tasks.filter((/** @type {any} */ t) => t.at >= from); + return { + count: hit.length, + longest: hit.reduce((m, /** @type {any} */ t) => Math.max(m, t.ms), 0), + busy: hit.reduce((m, /** @type {any} */ t) => m + t.ms, 0) + }; + }; + + /** Frame deltas for `ms`, optionally doing `each(dt)` every frame. */ + ns.frames = async (/** @type {number} */ ms, /** @type {any} */ each) => { + /** @type {number[]} */ + const deltas = []; + const started = performance.now(); + let last = await nextFrame(); + while (performance.now() - started < ms) { + const now = /** @type {number} */ (await nextFrame()); + deltas.push(now - last); + if (each) each(now - last); + last = now; + } + return { deltas, tasks: ns.tasksSince(started), elapsed: performance.now() - started }; + }; + + ns.renderer = () => { + const r = read(s.globalRenderer); + try { + const gl = r.getContext(); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + return dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); + } catch { + return 'unknown'; + } + }; + + ns.count = () => read(s.objectsGroup)?.children?.length ?? 0; + + /** + * Seed `n` boxes through the REAL create command (history, palette colour, + * shadow defaults, the poke) laid out on a square grid so every one is in + * frame. In chunks, yielding between them, because the thing being measured + * is the scene afterwards — not how badly a 10,000-iteration loop blocks. + */ + ns.seedCubes = async (/** @type {number} */ n, /** @type {number} */ chunk = 250) => { + const started = performance.now(); + const side = Math.ceil(Math.sqrt(n)); + const gap = 1.6; + const base = ns.count(); + for (let i = 0; i < n; i++) { + s.commandsHandler.sceneCommand('/create box'); + const o = read(s.selectedObject); + if (o?.position) o.position.set((i % side) * gap - (side * gap) / 2, 0.5, Math.floor(i / side) * gap - (side * gap) / 2); + if (i % chunk === chunk - 1) await sleep(0); + } + // the creations are synchronous, but the palette/shadow sweeps ride pokes + for (let t = 0; t < 200 && ns.count() < base + n; t++) await sleep(50); + s.selectedObjects?.set?.([]); + s.flushScenePokes?.(); + await nextFrame(); + return { ms: performance.now() - started, tasks: ns.tasksSince(started), count: ns.count() - base }; + }; + + /** Point the editor camera at the whole grid, from above and to one side. */ + ns.frameAll = (/** @type {number} */ n) => { + const side = Math.ceil(Math.sqrt(Math.max(1, n))) * 1.6; + const cam = read(s.globalCamera); + const controls = read(s.orbitControls); + const d = Math.max(12, side * 0.9); + cam.position.set(d * 0.6, d * 0.7, d * 0.8); + cam.far = Math.max(cam.far, d * 6); + cam.updateProjectionMatrix(); + controls?.target?.set?.(0, 0, 0); + controls?.update?.(); + }; + + /** A continuous orbit: what "the scene is heavy" feels like while navigating. */ + ns.orbit = (/** @type {number} */ ms) => { + const controls = read(s.orbitControls); + return ns.frames(ms, () => { + if (controls?._rotateLeft) controls._rotateLeft(0.02); + else if (controls?.rotateLeft) controls.rotateLeft(0.02); + controls?.update?.(); + }); + }; + + /** The budget sampler's own reading, after it has seen at least one window. */ + ns.metrics = async () => { + await sleep(600); + return s.sceneBudget.sampleSceneMetrics(); + }; + + /** One autosave snapshot through the real writer. */ + ns.autosave = async () => { + const started = performance.now(); + await s.autosave.saveNow(); + const status = read(s.autosave.autosaveStatus); + return { + wallMs: performance.now() - started, + exportMs: status.lastExportMs, + bytes: status.lastBytes, + error: status.lastError, + tasks: ns.tasksSince(started) + }; + }; + + /** + * Object list: close it, reopen it, and time until rows are in the DOM plus + * one painted frame. Above 500 rows 26-B windows the list, so the row count is + * reported too — a small count at 10k is the virtualisation working. + */ + ns.listRender = async () => { + s.objectListClose.set(true); + for (let t = 0; t < 40 && document.querySelector('#object-tree [role="treeitem"]'); t++) await sleep(25); + await nextFrame(); + const started = performance.now(); + s.objectListClose.set(false); + let rows = 0; + for (let t = 0; t < 400; t++) { + rows = document.querySelectorAll('#object-tree [role="treeitem"]').length; + if (rows > 0) break; + await new Promise((r) => setTimeout(r, 0)); + } + await nextFrame(); + return { ms: performance.now() - started, rows, mode: document.querySelector('[data-object-rows]')?.getAttribute('data-object-rows') ?? null }; + }; + + /** + * Import a dense model through the real GLB import path: a UV sphere of about + * `tris` triangles, exported to binary glTF in the page, handed to + * `fileHandler.importFile`. Timed until the object is in the scene. + */ + ns.importDense = async (/** @type {number} */ tris) => { + const THREE = s.THREE; + const Exporter = s.GLTFExporterModule.GLTFExporter; + // a UV sphere of w x h segments has about 2*w*(h-1) triangles + const h = Math.max(4, Math.round(Math.sqrt(tris / 2))); + const wSeg = Math.max(4, Math.round(tris / (2 * (h - 1)))); + const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, wSeg, h), new THREE.MeshStandardMaterial({ color: 0x8899aa })); + mesh.name = 'dense'; + const glb = await new Promise((resolve, reject) => + new Exporter().parse(mesh, resolve, reject, { binary: true }) + ); + mesh.geometry.dispose(); + const file = new File([/** @type {any} */ (glb)], 'dense.glb', { type: 'model/gltf-binary' }); + const before = ns.count(); + const started = performance.now(); + await s.fileHandler.importFile(file, 'dense', 'glb', [ (before % 6) * 2.5 - 6, 1, Math.floor(before / 6) * 2.5 - 6 ]); + for (let t = 0; t < 600 && ns.count() <= before; t++) await sleep(20); + s.selectedObjects?.set?.([]); + return { ms: performance.now() - started, bytes: /** @type {any} */ (glb).byteLength, landed: ns.count() > before, tasks: ns.tasksSince(started) }; + }; + + /** Run the simulation over what is in the scene for `ms`. */ + ns.physics = async (/** @type {number} */ ms) => { + const physics = s.physics; + if (!read(physics.simulating)) await physics.toggleSimulation(); + for (let t = 0; t < 100 && !read(physics.simulating); t++) await sleep(50); + const startedOk = !!read(physics.simulating); + await sleep(400); // the first steps build the world + const run = await ns.frames(ms); + const step = physics.physicsStepStats?.() ?? null; + const metrics = s.sceneBudget.sampleSceneMetrics(); + const stillRunning = !!read(physics.simulating); + if (stillRunning) physics.stopSimulation(); + return { startedOk, stillRunning, step, bodies: metrics.bodies ?? null, run }; + }; + } + return w.__stress.renderer(); + }); +} + +/** + * The per-size measurement, on a FRESH page so one size's heap and GPU state never + * colours the next. Returns one report row. Every field is a number or null; nothing is + * a string that a table would have to parse. + * @param {any} h helpers.cjs + * @param {any} browser + * @param {{kind: 'cubes'|'dense', size: number, windowMs?: number, physics?: boolean, autosave?: boolean, denseTris?: number, storage?: Record, viewport?: {width: number, height: number}}} opts + */ +async function measureScene(h, browser, opts) { + const windowMs = opts.windowMs ?? 4000; + const peer = await h.setupPage(browser, opts.kind + '-' + opts.size, { + context: { viewport: opts.viewport ?? { width: 1280, height: 720 } }, + storage: opts.storage + }); + try { + const gpu = await installProbe(peer.page); + /** @type {any} */ + const row = { kind: opts.kind, size: opts.size, gpu }; + const emptyIdle = await peer.page.evaluate((ms) => window.__stress.frames(ms), Math.min(2000, windowMs)); + row.emptyFrame = summarize(emptyIdle.deltas); + + if (opts.kind === 'cubes') { + const seed = await peer.page.evaluate((n) => window.__stress.seedCubes(n), opts.size); + row.seedMs = Math.round(seed.ms); + row.seedLongTasks = seed.tasks.count; + row.seedLongestTask = Math.round(seed.tasks.longest); + row.objects = seed.count; + await peer.page.evaluate((n) => window.__stress.frameAll(n), opts.size); + } else { + const tris = opts.denseTris ?? 200000; + /** @type {number[]} */ + const imports = []; + let bytes = 0; + let landed = 0; + let longest = 0; + for (let i = 0; i < opts.size; i++) { + const one = await peer.page.evaluate((t) => window.__stress.importDense(t), tris); + imports.push(one.ms); + bytes = one.bytes; + if (one.landed) landed++; + longest = Math.max(longest, one.tasks.longest); + } + row.importMsP50 = Math.round(summarize(imports).p50 ?? 0); + row.importMsMax = Math.round(summarize(imports).max ?? 0); + row.importLongestTask = Math.round(longest); + row.glbBytes = bytes; + row.objects = landed; + await peer.page.evaluate(() => window.__stress.frameAll(36)); + } + await peer.page.waitForTimeout(1200); + + const idle = await peer.page.evaluate((ms) => window.__stress.frames(ms), windowMs); + row.idle = summarize(idle.deltas); + row.idleLongTasks = idle.tasks.count; + const orbit = await peer.page.evaluate((ms) => window.__stress.orbit(ms), windowMs); + row.orbit = summarize(orbit.deltas); + row.orbitLongTasks = orbit.tasks.count; + row.orbitLongestTask = Math.round(orbit.tasks.longest); + + const m = await peer.page.evaluate(() => window.__stress.metrics()); + row.triangles = m.triangles; + row.calls = m.calls; + row.rendersPerFrame = m.rendersPerFrame ?? null; + row.geometries = m.geometries; + row.textures = m.textures; + row.heapMB = m.heap ? Math.round(m.heap / 1048576) : null; + row.meterP95 = m.frameP95; + + const list = await peer.page.evaluate(() => window.__stress.listRender()); + row.listMs = Math.round(list.ms); + row.listRows = list.rows; + row.listMode = list.mode; + + if (opts.autosave !== false) { + const save = await peer.page.evaluate(() => window.__stress.autosave()); + row.autosaveExportMs = save.exportMs ? Math.round(save.exportMs) : null; + row.autosaveWallMs = Math.round(save.wallMs); + row.autosaveBytes = save.bytes || null; + row.autosaveLongestTask = Math.round(save.tasks.longest); + row.autosaveError = save.error || null; + } + + if (opts.physics) { + const run = await peer.page.evaluate((ms) => window.__stress.physics(ms), windowMs); + row.physicsStarted = run.startedOk; + row.physicsAutoStopped = run.startedOk && !run.stillRunning; + row.bodies = run.bodies; + row.stepP50 = run.step ? Math.round(run.step.p50 * 10) / 10 : null; + row.stepP95 = run.step ? Math.round(run.step.p95 * 10) / 10 : null; + row.physicsFrame = summarize(run.run.deltas); + } + row.pageErrors = h.pageErrors(peer).length; + return row; + } finally { + await peer.ctx.close(); + } +} + +module.exports = { percentile, summarize, installProbe, measureScene }; From d9a15ed7348c3fc6a4001170fe612dc531dce1e8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 09:22:16 +0300 Subject: [PATCH 2/3] [feat] 25-G: the mesh regression runs on four peers and its own signaling, and the rig measures presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 25 section 3d, and the N=4 regression 27-I's brief asked for. - tests/e2e/localSignal.cjs: the local `peer` server on :9001 (extracted from the rig), REUSED when one already answers (the port is machine-wide), plus LOCAL_PEER_STORAGE = peerServerConfig {mode:'local'}. Seeding the pages is what keeps them off production; the old "APP_URL must be localhost" check is now "must resolve to this machine", so a lane serving theprototype.app via /etc/hosts can run the rig. - net-stress.test.cjs: FOUR peers on the local server (was three on the shared box). With three, the host's `hosts` roster only ever names one other peer, so a fill that mishandled a longer list still passed; with four, six of the twelve links come from the fill alone. Checks: every ordered pair open (pair-complete), host broadcast whole to all three, all four blasting at once (12/12 pairs whole, counters reset first — the running-maximum trap), fan-out bounded, and NEW: the presence stream while all four orbit, stated as messages/s per sender against a premise that every sender drew well above the 20/s gate (so a per-frame sender would be visible), plus long tasks. - net-stress.cjs: default sizes 8,10,12,16; `--presence N` (every peer orbits for N seconds, each counts camera messages RECEIVED per sender); a long-tasks/min column on every load step; a presence table. Measured (Radeon 890M box, ALL peers on one machine, local signaling, 20 objects): - full mesh at 8, 10, 12 and 16 peers - 0% loss up to 3,360 (N=8), 10,800 (N=10) and 7,920 (N=12) mesh msgs/s; 0.24% at 15,840 (N=12); at N=16 0.07% even at 10Hz, 1.87% at 28,800 msgs/s — the box is saturated there (16 GPU contexts, idle 34fps, echo RTT p95 550ms) - presence received per peer: 132/s (N=8), 169/s (N=10), 207/s (N=12), 260/s (N=16); 0.31-0.36 messages per sender frame at 60fps, i.e. the 25-C gate holds at scale - long tasks/min 0 at N<=12; frame drop with N is GPU/compositor contention, not the main thread Counterfactuals (each broken, suite red, restored): - mesh fill disabled (hosts -> no connectToPeer): 6 of 12 pairs missing, 6/12 pairs deliver under four-way load - camera gate removed (camGapMs 0): 59.9 msgs/s per sender against 18.2 with it Suites: net-stress.test 15/15 (was 10/10 on three peers). svelte-check 352/47 (base 352/47). npm run build green. Co-Authored-By: Claude Opus 5 --- tests/e2e/localSignal.cjs | 74 +++++++++ tests/e2e/net-stress.cjs | 206 ++++++++++++++++++------- tests/e2e/net-stress.test.cjs | 280 +++++++++++++++++++++++----------- 3 files changed, 416 insertions(+), 144 deletions(-) create mode 100644 tests/e2e/localSignal.cjs diff --git a/tests/e2e/localSignal.cjs b/tests/e2e/localSignal.cjs new file mode 100644 index 00000000..2d918b67 --- /dev/null +++ b/tests/e2e/localSignal.cjs @@ -0,0 +1,74 @@ +// A LOCAL PeerJS signaling server for the multi-peer stress runs (net-stress rig and its +// regression suite). Flooding the production signaling box with a mesh sweep is abuse, +// and a shared box on a saturated machine is also the most common source of a two-peer +// red that has nothing to do with the diff — so these runs bring their own. +// +// Port 9001 is MACHINE-WIDE: two lanes share it. Everything that starts it runs under the +// e2e flock, and a server already listening is REUSED rather than fought over. +// +// Pages reach it through `peerServerConfig = {mode:'local'}` (peerServer.js), seeded with +// `LOCAL_PEER_STORAGE` — never by guessing from the page's hostname. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawn } = require('child_process'); + +const SIGNAL_PORT = 9001; +const ROOT = path.resolve(__dirname, '..', '..'); +const LOCAL_PEER_STORAGE = { peerServerConfig: JSON.stringify({ mode: 'local' }) }; + +/** @param {number} ms */ +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function signalUp() { + return new Promise((resolve) => { + const req = https.get( + { host: 'localhost', port: SIGNAL_PORT, path: '/', rejectUnauthorized: false, timeout: 1500 }, + (res) => { + res.resume(); + resolve(res.statusCode === 200); + } + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** Start the server unless one already answers. Returns the child to stop, or null. */ +async function ensureSignalServer() { + if (await signalUp()) return null; + const bin = path.join(ROOT, 'node_modules', 'peer', 'dist', 'bin', 'peerjs.js'); + const key = path.join(ROOT, 'certs', 'localhost.key'); + const crt = path.join(ROOT, 'certs', 'localhost.crt'); + if (!fs.existsSync(bin)) throw new Error('the `peer` devDependency is missing — run npm ci'); + if (!fs.existsSync(key)) throw new Error('certs/localhost.key missing — copy certs/ from another checkout'); + const child = spawn(process.execPath, [bin, '--port', String(SIGNAL_PORT), '--sslkey', key, '--sslcert', crt], { + cwd: ROOT, + stdio: 'ignore' + }); + for (let i = 0; i < 40; i++) { + await sleep(250); + if (await signalUp()) return child; + } + try { + child.kill(); + } catch { + /* already gone */ + } + throw new Error('local PeerJS server did not come up on :' + SIGNAL_PORT); +} + +/** @param {any} child */ +function stopSignalServer(child) { + if (!child) return; + try { + child.kill(); + } catch { + /* already gone */ + } +} + +module.exports = { SIGNAL_PORT, LOCAL_PEER_STORAGE, signalUp, ensureSignalServer, stopSignalServer }; diff --git a/tests/e2e/net-stress.cjs b/tests/e2e/net-stress.cjs index 3c776ab2..e80de918 100644 --- a/tests/e2e/net-stress.cjs +++ b/tests/e2e/net-stress.cjs @@ -1,7 +1,7 @@ // B5 — mesh network stress harness (LOCAL PeerJS ONLY). // -// node tests/e2e/net-stress.cjs [--peers 4,6,8,10] [--load 20] [--objects 20] -// [--out docs/net-stress.md] [--hz 10] +// node tests/e2e/net-stress.cjs [--peers 8,10,12,16] [--load 20] [--objects 20] +// [--out docs/net-stress.md] [--hz 10] [--presence 10] // // NOT a .test.cjs on purpose: a full sweep runs for many minutes, well past the // runner's per-suite timeout. `npm run e2e -- net-stress` runs the small @@ -14,20 +14,25 @@ // - message loss — sequence numbers over a synthetic mutation load // - fan-out cost — wall time of one PeerConnection.send() across N-1 conns // - renderer FPS — idle baseline vs under load (relative; see the caveat below) +// - long tasks/min — main-thread blocks over 50ms per peer under the load (25-G) +// - presence — with --presence N, every peer orbits its camera for N seconds and +// each counts the `camera` messages it RECEIVES per sender: the +// audit-H7 stream, now rate-gated (25-C), at mesh scale (25-G) // // HARD RULE: local signaling server only. Pointing a 10-peer flood at the public -// or self-hosted production box is abuse, so the harness refuses any APP_URL that -// isn't localhost and spawns its own `peer` server on :9001 (the same one the -// .vscode "peerjs" task starts). +// or self-hosted production box is abuse, so the harness spawns its own `peer` server on +// :9001 (localSignal.cjs) and SEEDS every page with `peerServerConfig = {mode:'local'}` — +// which is what actually keeps the pages off production, whatever the app's hostname. +// The APP_URL must still resolve to this machine (a lane serves theprototype.app via +// /etc/hosts), so the dev server being flooded is our own. // // CAVEAT on FPS: N headless Chromium contexts each render a WebGL scene on the -// same machine (SwiftShader, no GPU), so absolute FPS says more about the host -// than about the protocol. Only the idle-vs-load DELTA at a given N is meaningful. +// same machine, so absolute FPS says more about the host than about the protocol. +// Only the idle-vs-load DELTA at a given N is meaningful. The rig launches with +// GPU_ARGS; on a box without a GPU that silently falls back to SwiftShader. const fs = require('fs'); const path = require('path'); -const https = require('https'); -const { spawn } = require('child_process'); // ---------------------------------------------------------------- arguments const argv = process.argv.slice(2); @@ -36,7 +41,7 @@ function arg(name, fallback) { const i = argv.indexOf('--' + name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; } -const SIZES = arg('peers', '4,6,8,10') +const SIZES = arg('peers', '8,10,12,16') .split(',') .map((n) => parseInt(n, 10)) .filter((n) => n >= 2); @@ -44,6 +49,8 @@ const LOAD_SECS = parseInt(arg('load', '20'), 10); const HZ = parseInt(arg('hz', '10'), 10); const OBJECTS = parseInt(arg('objects', '20'), 10); const OUT = arg('out', ''); +// 25-G: seconds of continuous camera motion on every peer; 0 = skip the presence phase +const PRESENCE_SECS = parseInt(arg('presence', '0'), 10); // --logs echoes each page's own console (peerHandler is chatty about the connect // dance) with a ms stamp, which is the only way to see WHY a join stalls const LOGS = argv.includes('--logs'); @@ -53,52 +60,17 @@ const T0 = Date.now(); const APP_URL = process.env.APP_URL || 'https://localhost:5185/'; process.env.APP_URL = APP_URL; const host = new URL(APP_URL).hostname; -if (!/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(host)) { - console.error( - 'REFUSING to run: APP_URL host is "' + host + '".\n' + - 'The stress harness floods the signaling server and must only ever point at a\n' + - 'LOCAL dev server (which routes PeerJS to localhost:9001). See the file header.' - ); - process.exit(2); -} - const h = require('./helpers.cjs'); - -// ------------------------------------------------------- local peerjs server -const SIGNAL_PORT = 9001; - -function signalUp() { - return new Promise((resolve) => { - const req = https.get( - { host: 'localhost', port: SIGNAL_PORT, path: '/', rejectUnauthorized: false, timeout: 1500 }, - (res) => { - res.resume(); - resolve(res.statusCode === 200); - } - ); - req.on('error', () => resolve(false)); - req.on('timeout', () => { req.destroy(); resolve(false); }); - }); -} - -async function ensureSignalServer() { - if (await signalUp()) return null; - const bin = path.join(ROOT, 'node_modules', 'peer', 'dist', 'bin', 'peerjs.js'); - const key = path.join(ROOT, 'certs', 'localhost.key'); - const crt = path.join(ROOT, 'certs', 'localhost.crt'); - if (!fs.existsSync(bin)) throw new Error('the `peer` devDependency is missing — run npm ci'); - if (!fs.existsSync(key)) throw new Error('certs/localhost.key missing — run npm run certs'); - console.log('starting local PeerJS server on :' + SIGNAL_PORT); - const child = spawn(process.execPath, [bin, '--port', String(SIGNAL_PORT), '--sslkey', key, '--sslcert', crt], { - cwd: ROOT, - stdio: 'ignore' - }); - for (let i = 0; i < 40; i++) { - await sleep(250); - if (await signalUp()) return child; - } - try { child.kill(); } catch { /* already gone */ } - throw new Error('local PeerJS server did not come up on :' + SIGNAL_PORT); +const { SIGNAL_PORT, LOCAL_PEER_STORAGE, ensureSignalServer } = require('./localSignal.cjs'); + +/** Does the APP_URL host resolve to this machine? @param {string} name */ +function isLoopback(name) { + if (/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(name)) return Promise.resolve(true); + return new Promise((resolve) => + require('dns').lookup(name, { all: true }, (err, addrs) => + resolve(!err && addrs.length > 0 && addrs.every((a) => a.address === '127.0.0.1' || a.address === '::1')) + ) + ); } // ------------------------------------------------------------------- utils @@ -147,9 +119,43 @@ function installProbe(peer) { // per-type traffic accounting — ON during joins (where the interesting // asymmetry is), OFF under load so the sizing cost can't skew FPS accounting: true, - traffic: { count: 0, bytes: 0, byType: {} } + traffic: { count: 0, bytes: 0, byType: {} }, + // 25-G: camera messages received per SENDER, and the main thread's long tasks + cam: {}, + tasks: [] }); ns.pc = pc; + if (!ns.taskObserver) { + try { + ns.taskObserver = new PerformanceObserver((list) => { + for (const e of list.getEntries()) ns.tasks.push(e.startTime); + }); + ns.taskObserver.observe({ entryTypes: ['longtask'] }); + } catch { + ns.taskObserver = null; + } + } + /** long tasks that started in the last `ms` */ + ns.tasksIn = (/** @type {number} */ ms) => ns.tasks.filter((/** @type {number} */ t) => t >= performance.now() - ms).length; + /** orbit the editor camera every frame until stopped — the presence stream's source */ + ns.orbitStart = () => { + let controls; + w.__stores.orbitControls.subscribe((/** @type {any} */ c) => (controls = c))(); + ns.orbitFrames = 0; + ns.orbiting = true; + const tick = () => { + if (!ns.orbiting) return; + if (controls?._rotateLeft) controls._rotateLeft(0.03); + controls?.update?.(); + ns.orbitFrames++; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }; + ns.orbitStop = () => { + ns.orbiting = false; + return ns.orbitFrames; + }; /** rough wire size; binarypack is compact but relative sizes are what matter */ ns.sizeOf = (/** @type {any} */ d) => { @@ -196,6 +202,7 @@ function installProbe(peer) { ns.hooked.add(c); added++; c.on('data', (/** @type {any} */ d) => { + if (d && d.type === 'camera' && d.peerId) ns.cam[d.peerId] = (ns.cam[d.peerId] || 0) + 1; if (ns.accounting && d) { const t = typeof d === 'string' ? 'string' : d.type || 'unknown'; const tr = ns.traffic; @@ -364,7 +371,7 @@ async function runSize(N) { // measures render starvation. We want the NETWORK to be the bottleneck. const p = await h.setupPage(browser, 'P' + i, { context: { viewport: { width: 800, height: 600 } }, - storage: { viewMode: 'shaded' } + storage: { viewMode: 'shaded', ...LOCAL_PEER_STORAGE } }); if (LOGS) { const tag = 'P' + i + '/' + p.id; @@ -499,12 +506,65 @@ async function runSize(N) { // --- load: every peer broadcasts `move` at hz for `secs`, then a ramp to // find where the mesh actually starts hurting + /** + * 25-G: every peer orbits its camera for `secs`, and each counts the `camera` messages + * it RECEIVES per sender. The rate is per sender per receiver, stated beside the + * sender's own frame count — a 50ms gate at 60fps is ~0.33 messages a frame. + * @param {number} secs + */ + const presencePhase = async (secs) => { + for (const p of peers) await p.page.evaluate(() => window.__ns.hook()); + for (const p of peers) await p.page.evaluate(() => { window.__ns.cam = {}; }); + for (const p of peers) await p.page.evaluate(() => window.__ns.orbitStart()); + await sleep(secs * 1000); + const frames = []; + for (const p of peers) frames.push(await p.page.evaluate(() => window.__ns.orbitStop())); + const longPerMin = []; + for (const p of peers) longPerMin.push(await p.page.evaluate((ms) => window.__ns.tasksIn(ms), secs * 1000)); + await sleep(1000); + /** received camera msgs/s per peer, summed over every sender */ + const receivedPerPeer = []; + /** per sender->receiver pair, msgs per sender frame */ + const perFrame = []; + let pairsSilent = 0; + for (let i = 0; i < N; i++) { + const cam = await peers[i].page.evaluate(() => ({ ...window.__ns.cam })); + let total = 0; + for (let j = 0; j < N; j++) { + if (i === j) continue; + const got = cam[peers[j].id] || 0; + total += got; + if (!got) pairsSilent++; + if (frames[j]) perFrame.push(got / frames[j]); + } + receivedPerPeer.push(total / secs); + } + const out = { + secs, + senderFps: median(frames.map((f) => f / secs)), + receivedPerPeerPerSec: median(receivedPerPeer), + maxReceivedPerPeerPerSec: Math.max(...receivedPerPeer), + msgsPerSenderFrame: stats(perFrame), + pairsSilent, + longTasksPerMin: median(longPerMin.map((n) => (n * 60) / secs)), + maxLongTasksPerMin: Math.max(...longPerMin.map((n) => (n * 60) / secs)) + }; + console.log( + ' presence: ' + r(out.receivedPerPeerPerSec) + ' camera msgs/s received per peer (max ' + r(out.maxReceivedPerPeerPerSec) + ')' + + ', ' + r(out.msgsPerSenderFrame.p50, 2) + ' msgs per sender frame, sender fps ' + r(out.senderFps) + + ', silent pairs ' + pairsSilent + ', long tasks/min ' + r(out.longTasksPerMin) + ' (max ' + r(out.maxLongTasksPerMin) + ')' + ); + return out; + }; + /** @param {number} hz @param {number} secs */ const loadPhase = async (hz, secs) => { for (const p of peers) await p.page.evaluate(() => window.__ns.hook()); for (const p of peers) await p.page.evaluate(() => window.__ns.fpsStart()); for (const p of peers) await p.page.evaluate(([u, z]) => window.__ns.startLoad(u, z), [uuid, hz]); await sleep(secs * 1000); + const longPerMin = []; + for (const p of peers) longPerMin.push(await p.page.evaluate((ms) => window.__ns.tasksIn(ms), secs * 1000)); const sent = []; for (const p of peers) sent.push(await p.page.evaluate(() => window.__ns.stopLoad())); const fps = []; @@ -549,6 +609,7 @@ async function runSize(N) { sendMs: stats(sendMs), oneWay: stats(lat), fps: median(fps), + longTasksPerMin: median(longPerMin.map((n) => (n * 60) / secs)), msgs: { expected, got, lossPct: expected ? (100 * (expected - got)) / expected : 0 }, meshMsgsPerSec: hz * N * (N - 1) }; @@ -564,6 +625,7 @@ async function runSize(N) { }; row.steady = await loadPhase(HZ, LOAD_SECS); + if (PRESENCE_SECS > 0) row.presence = await presencePhase(PRESENCE_SECS); row.ramp = []; for (const hz of [30, 60, 120]) row.ramp.push(await loadPhase(hz, 8)); @@ -624,8 +686,8 @@ function report(rows) { lines.push(''); lines.push('## Load ramp (8s per step; "emitted" = what the send timer actually managed)'); lines.push(''); - lines.push('| N | Hz/peer | mesh msgs/s | loss | one-way p50/p95 | send() p95 | fps | emitted/wanted |'); - lines.push('|---|---|---|---|---|---|---|---|'); + lines.push('| N | Hz/peer | mesh msgs/s | loss | one-way p50/p95 | send() p95 | fps | long tasks/min | emitted/wanted |'); + lines.push('|---|---|---|---|---|---|---|---|---|'); for (const w of rows) { for (const s of [w.steady, ...(w.ramp || [])]) { if (!s) continue; @@ -634,10 +696,29 @@ function report(rows) { ' | ' + r(s.oneWay.p50) + ' / ' + r(s.oneWay.p95) + ' | ' + r(s.sendMs.p95, 2) + ' | ' + r(s.fps) + + ' | ' + r(s.longTasksPerMin) + ' | ' + r(s.sentPerPeer, 0) + '/' + s.wantedPerPeer + ' |' ); } } + if (rows.some((w) => w.presence)) { + lines.push(''); + lines.push('## Presence (25-G): every peer orbiting for ' + PRESENCE_SECS + 's'); + lines.push(''); + lines.push('| N | sender fps | camera msgs/s received per peer (median / max) | msgs per sender frame p50/max | silent pairs | long tasks/min (median / max) |'); + lines.push('|---|---|---|---|---|---|'); + for (const w of rows) { + const p = w.presence; + if (!p) continue; + lines.push( + '| ' + w.N + ' | ' + r(p.senderFps) + + ' | ' + r(p.receivedPerPeerPerSec) + ' / ' + r(p.maxReceivedPerPeerPerSec) + + ' | ' + r(p.msgsPerSenderFrame.p50, 2) + ' / ' + r(p.msgsPerSenderFrame.max, 2) + + ' | ' + p.pairsSilent + + ' | ' + r(p.longTasksPerMin) + ' / ' + r(p.maxLongTasksPerMin) + ' |' + ); + } + } lines.push(''); lines.push('```json'); lines.push(JSON.stringify(rows, null, 1)); @@ -649,6 +730,13 @@ function report(rows) { (async () => { let server = null; try { + if (!(await isLoopback(host))) { + console.error( + 'REFUSING to run: APP_URL host "' + host + '" does not resolve to this machine.\n' + + 'The rig floods its dev server and must only ever point at a LOCAL one. See the file header.' + ); + process.exit(2); + } server = await ensureSignalServer(); console.log('app: ' + APP_URL + ' signaling: https://localhost:' + SIGNAL_PORT); const rows = []; diff --git a/tests/e2e/net-stress.test.cjs b/tests/e2e/net-stress.test.cjs index 8dfb67d1..ef68d23b 100644 --- a/tests/e2e/net-stress.test.cjs +++ b/tests/e2e/net-stress.test.cjs @@ -1,35 +1,53 @@ -// 27-I — THE SMALL MESH REGRESSION SUITE. +// 27-I + 25-G — THE MESH REGRESSION SUITE, on FOUR peers and a LOCAL signaling server. // -// `net-stress.cjs` beside this file is the MEASUREMENT RIG: a many-minute sweep across -// mesh sizes that spawns its own signaling server and refuses any non-localhost APP_URL. -// Its header has always pointed at this file for the quick check, and this file did not -// exist — so `npm run e2e -- net-stress` matched the rig's name and ran nothing. +// `net-stress.cjs` beside this file is the MEASUREMENT RIG (a many-minute sweep across +// mesh sizes). This is the quick check that would catch a real regression in what the rig +// measures. 27-I shipped it on three peers against the shared signaling box; 25-G makes it +// what that brief asked for: +// - N=4, because with three peers the host's `hosts` roster only ever names ONE other +// peer, so a fill that mishandled a list longer than one (only the first id, only the +// last) would still pass. With four, each fill has to reach two peers that never +// dialled each other — six of the twelve links come from the fill alone. +// - a LOCAL `peer` server on :9001 (localSignal.cjs), so a signaling hiccup on a shared +// box can no longer masquerade as a mesh regression, and nothing floods production. // -// What this pins, on a THREE-peer mesh, is the handful of properties the rig measures -// that would be a real regression if they broke: -// 1. the mesh FILLS — a late joiner dials one peer and ends up connected to both +// What this pins: +// 1. the mesh FILLS — every one of the 12 ordered pairs is open (pair-complete: a link +// that never formed is exactly the loss a user feels) // 2. a broadcast reaches every peer with NO loss, by sequence number -// 3. one send's fan-out cost stays bounded (it is a per-conn loop, never batched) -// 4. the same, while a second sender is loading the mesh — nobody starves +// 3. all FOUR broadcasting at once: every ordered pair delivers whole — nobody starves +// 4. one send's fan-out cost stays bounded +// 5. the PRESENCE stream (roadmap 25 3c/3d, audit H7): four peers orbiting at display +// rate send `camera` at the gated rate (20/s desktop), NOT once per frame — with a +// premise that every sender drew well above the gate, so per-frame would be visible +// 6. the main thread under that load: long tasks per peer are recorded and bounded // -// The probe rides a REAL `move` payload with additive `__ns` fields, which is the rig's -// own trick and matters twice over: it exercises the real applier path, and since 27-A -// validates every incoming message, a made-up uuid would be REJECTED by that guard — so -// the probe carries an actual object's uuid. +// The probe rides a REAL `move` payload with additive `__ns` fields: 27-A validates every +// incoming message, so a made-up uuid would be rejected — the probe carries a real uuid. // -// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- net-stress.test +// Run: APP_URL=https://theprototype.app:5180/ npm run e2e -- net-stress.test const h = require('./helpers.cjs'); +const { LOCAL_PEER_STORAGE, ensureSignalServer, stopSignalServer } = require('./localSignal.cjs'); -/** A reduced installProbe: hook every conn, count probe messages per sender by seq. */ +/** Hook every conn; count probe messages per sender by seq, and presence per sender. */ const installProbe = (peer) => peer.page.evaluate((myId) => { const w = window; let pc; w.__stores.peers.subscribe((p) => (pc = p))(); - const ns = (w.__probe = w.__probe || { myId, hooked: new WeakSet(), rx: {}, sendMs: [], seq: 0 }); + const ns = (w.__probe = w.__probe || { myId, hooked: new WeakSet(), rx: {}, cam: {}, sendMs: [], tasks: [] }); ns.pc = pc; - // the app's outgoing map AND peerjs's own, which also holds INBOUND conns — an - // ack can come back over a conn this peer never dialled + if (!ns.observer) { + try { + ns.observer = new PerformanceObserver((list) => { + for (const e of list.getEntries()) ns.tasks.push({ at: e.startTime, ms: e.duration }); + }); + ns.observer.observe({ entryTypes: ['longtask'] }); + } catch { + ns.observer = null; + } + } + // the app's outgoing map AND peerjs's own, which also holds INBOUND conns ns.allConns = () => { const seen = new Set(); const out = []; @@ -48,7 +66,9 @@ const installProbe = (peer) => if (ns.hooked.has(c)) continue; ns.hooked.add(c); c.on('data', (d) => { - if (!d || d.__ns !== 'probe') return; + if (!d) return; + if (d.type === 'camera' && d.peerId) ns.cam[d.peerId] = (ns.cam[d.peerId] || 0) + 1; + if (d.__ns !== 'probe') return; const s = ns.rx[d.__from] || (ns.rx[d.__from] = { count: 0, maxSeq: -1 }); s.count++; if (d.__seq > s.maxSeq) s.maxSeq = d.__seq; @@ -56,7 +76,6 @@ const installProbe = (peer) => } return ns.allConns().length; }; - // conns keep appearing through the join phase, so keep re-scanning ns.hook(); if (!ns.auto) ns.auto = setInterval(() => ns.hook(), 250); ns.send = (uuid, seq) => { @@ -74,10 +93,11 @@ const installProbe = (peer) => ns.sendMs.push(performance.now() - t); }; // `maxSeq` is a RUNNING MAXIMUM and `count` accumulates, so a later section that - // sends fewer messages than an earlier one cannot lower either — without this the - // two-way check below passes on numbers left over from the first blast. + // sends fewer messages than an earlier one cannot lower either — every section that + // counts starts from a reset, or it passes on numbers left over from the last one. ns.reset = () => { ns.rx = {}; + ns.cam = {}; }; ns.blast = async (uuid, count, gapMs) => { ns.sendMs = []; @@ -87,6 +107,28 @@ const installProbe = (peer) => } return { sent: count, maxSendMs: Math.max(...ns.sendMs) }; }; + // orbit the editor camera every frame for `ms`, counting our own frames — the + // presence stream's send rate is stated against THIS number + ns.orbit = async (ms) => { + let controls; + w.__stores.orbitControls.subscribe((c) => (controls = c))(); + const started = performance.now(); + let frames = 0; + const taskFrom = ns.tasks.length; + while (performance.now() - started < ms) { + await new Promise((r) => requestAnimationFrame(r)); + if (controls?._rotateLeft) controls._rotateLeft(0.03); + controls?.update?.(); + frames++; + } + const tasks = ns.tasks.slice(taskFrom); + return { + frames, + elapsed: performance.now() - started, + longTasks: tasks.length, + longest: tasks.reduce((m, t) => Math.max(m, t.ms), 0) + }; + }; return true; }, peer.id); @@ -96,81 +138,149 @@ const received = (peer, fromId) => return s ? { count: s.count, maxSeq: s.maxSeq } : { count: 0, maxSeq: -1 }; }, fromId); -const openConns = (peer) => +const openPeers = (peer) => peer.page.evaluate(() => { let pc; window.__stores.peers.subscribe((p) => (pc = p))(); - return pc?.openedPeers?.size ?? 0; + return [...(pc?.openedPeers ?? [])]; }); h.run(async () => { - const browser = await h.launch(); - const A = await h.setupPage(browser, 'A'); - const B = await h.setupPage(browser, 'B'); - const C = await h.setupPage(browser, 'C'); + /** @type {any} */ + let browserRef = null; + const signal = await ensureSignalServer(); + try { + // GPU args: section 5 is a RATE claim against display frames, and a SwiftShader page + // at ~2.5fps can never exercise a 50ms gate (the e2e skill's rule) + const browser = (browserRef = await h.launch({ args: h.GPU_ARGS })); + const opts = { storage: LOCAL_PEER_STORAGE, context: { viewport: { width: 800, height: 600 } } }; + const peers = []; + for (const name of ['A', 'B', 'C', 'D']) peers.push(await h.setupPage(browser, name, opts)); + const [A, B, C, D] = peers; + const server = await A.page.evaluate(() => { + let s; + window.__stores.peerServer.peerServerStatus.subscribe((v) => (s = v))(); + return s; + }); + h.check(server?.kind === 'local', `premise: the peers signal through the LOCAL server (${JSON.stringify(server)})`); - // ---- 1. the mesh fills ------------------------------------------------------------- - await h.connect(B, A); - // a CONNECTED peer's pill has no dial input, so the late joiner dials the HOST - await h.connect(C, A); - await h.eventually(() => openConns(C), (n) => n >= 2, 'the late joiner ends up connected to BOTH peers', 30000); - await h.eventually(() => openConns(A), (n) => n >= 2, 'the host holds both connections', 20000); - await h.eventually(() => openConns(B), (n) => n >= 2, 'and the first joiner was filled in by the mesh', 20000); + // ---- 1. the mesh fills ----------------------------------------------------------- + // a CONNECTED peer's pill has no dial input, so every joiner dials the HOST + await h.connect(B, A); + await h.connect(C, A); + await h.connect(D, A); + /** every ordered pair (i sees j open) */ + const pairState = async () => { + const lists = []; + for (const p of peers) lists.push(await openPeers(p)); + const missing = []; + peers.forEach((p, i) => + peers.forEach((q, j) => { + if (i !== j && !lists[i].includes(q.id)) missing.push(`${'ABCD'[i]}->${'ABCD'[j]}`); + }) + ); + return missing; + }; + await h.eventually(pairState, (m) => m.length === 0, 'all 12 ordered pairs of a four-peer mesh are open', 45000); + const missing = await pairState(); + h.check( + missing.length === 0, + `the mesh is FULL — B, C and D each dialled only the host (missing: ${JSON.stringify(missing)})` + ); - // a REAL object, so the probe's `move` survives the 27-A wire validator - const uuid = await A.page.evaluate(() => { - window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [3, 0.5, -2]); - return new Promise((resolve) => - window.__stores.objectsGroup.subscribe((g) => { - const o = g.children[g.children.length - 1]; - resolve(o ? o.uuid : null); - })() + // a REAL object, so the probe's `move` survives the 27-A wire validator + const uuid = await A.page.evaluate(() => { + window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [3, 0.5, -2]); + return new Promise((resolve) => + window.__stores.objectsGroup.subscribe((g) => { + const o = g.children[g.children.length - 1]; + resolve(o ? o.uuid : null); + })() + ); + }); + h.check(!!uuid, `premise: a real object to address, so the probe is not rejected as malformed (${uuid})`); + await h.eventually( + () => D.page.evaluate((u) => !!window.__stores.objectsGroup && (() => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); return !!g.getObjectByProperty('uuid', u); })(), uuid), + (ok) => ok, + 'premise: the object reached the last joiner', + 15000 ); - }); - h.check(!!uuid, `premise: a real object to address, so the probe is not rejected as malformed (${uuid})`); - await A.page.waitForTimeout(800); - for (const p of [A, B, C]) await installProbe(p); - await A.page.waitForTimeout(600); + for (const p of peers) await installProbe(p); + await A.page.waitForTimeout(600); - // ---- 2. a broadcast reaches everyone, with no loss ---------------------------------- - const blast = await A.page.evaluate( - ([u, n, gap]) => window.__probe.blast(u, n, gap), - [uuid, 60, 25] - ); - h.check(blast.sent === 60, `premise: the host sent 60 probe messages (${blast.sent})`); - await A.page.waitForTimeout(1200); + // ---- 2. a broadcast reaches everyone, with no loss ------------------------------- + const blast = await A.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 60, 25]); + h.check(blast.sent === 60, `premise: the host sent 60 probe messages (${blast.sent})`); + await A.page.waitForTimeout(1200); + for (const p of [B, C, D]) { + const got = await received(p, A.id); + h.check(got.count === 60 && got.maxSeq === 59, `${p === B ? 'B' : p === C ? 'C' : 'D'} got every host message, tail included (${got.count}/60, maxSeq ${got.maxSeq})`); + } - const atB = await received(B, A.id); - const atC = await received(C, A.id); - h.check(atB.count === 60, `every message reached the first joiner (${atB.count}/60, maxSeq ${atB.maxSeq})`); - h.check(atC.count === 60, `every message reached the late joiner (${atC.count}/60, maxSeq ${atC.maxSeq})`); - h.check( - atB.maxSeq === 59 && atC.maxSeq === 59, - `and the LAST one arrived, so nothing was dropped off the tail (${atB.maxSeq}, ${atC.maxSeq})` - ); + // ---- 4. fan-out cost stays bounded ------------------------------------------------ + h.check(blast.maxSendMs < 250, `one broadcast's fan-out stays bounded (worst send ${blast.maxSendMs.toFixed(1)}ms across 3 conns)`); - // ---- 3. fan-out cost stays bounded -------------------------------------------------- - // `send` is a per-conn loop with no batching, so this is the number that grows with N. - h.check( - blast.maxSendMs < 250, - `one broadcast's fan-out stays bounded (worst send ${blast.maxSendMs.toFixed(1)}ms across 2 conns)` - ); + // ---- 3. all four at once: every ordered pair whole -------------------------------- + for (const p of peers) await p.page.evaluate(() => window.__probe.reset()); + await Promise.all(peers.map((p) => p.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 40, 25]))); + await A.page.waitForTimeout(1800); + let pairsWhole = 0; + const broken = []; + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) { + if (i === j) continue; + const got = await received(peers[i], peers[j].id); + if (got.count === 40 && got.maxSeq === 39) pairsWhole++; + else broken.push(`${'ABCD'[j]}->${'ABCD'[i]} ${got.count}/40`); + } + } + h.check(pairsWhole === 12, `under four-way load every ordered pair delivered whole (${pairsWhole}/12 ${JSON.stringify(broken)})`); - // ---- 4. two senders at once: nobody starves ----------------------------------------- - // clear the counters first, or section 2's seq 59 makes this check unfalsifiable - for (const p of [A, B, C]) await p.page.evaluate(() => window.__probe.reset()); - await Promise.all([ - A.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 40, 25]), - B.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 40, 25]) - ]); - await A.page.waitForTimeout(1500); - const cFromA = await received(C, A.id); - const cFromB = await received(C, B.id); - h.check( - cFromA.maxSeq === 39 && cFromB.maxSeq === 39 && cFromA.count === 40 && cFromB.count === 40, - `under two-way load the late joiner got both streams WHOLE (A ${cFromA.count}/40 seq ${cFromA.maxSeq}, B ${cFromB.count}/40 seq ${cFromB.maxSeq})` - ); + // ---- 5. the presence stream is throttled, not per frame --------------------------- + for (const p of peers) await p.page.evaluate(() => window.__probe.reset()); + const runs = await Promise.all(peers.map((p) => p.page.evaluate((ms) => window.__probe.orbit(ms), 3000))); + // read at once: OrbitControls damping keeps the camera drifting (and sending) after the + // orbit loop stops, and those messages belong to no measured frame window + const cams = []; + for (const p of peers) cams.push(await p.page.evaluate(() => ({ ...window.__probe.cam }))); + // the claim is only testable when a per-frame sender would EXCEED the gate: 25-C gates + // the desktop camera at 50ms (20/s), so every peer must be drawing well above that + h.check( + runs.every((r) => (r.frames * 1000) / r.elapsed >= 40), + `premise: every peer ran well above the 20/s gate while orbiting (${runs.map((r) => Math.round((r.frames * 1000) / r.elapsed)).join(', ')} fps)` + ); + // per SENDER, as seen by every other peer, in messages per second of orbit + const rates = []; + let flowing = true; + peers.forEach((sender, j) => { + peers.forEach((_, i) => { + if (i === j) return; + const got = cams[i][sender.id] || 0; + if (got < 10) flowing = false; + rates.push(Math.round((got / (runs[j].elapsed / 1000)) * 10) / 10); + }); + }); + h.check(flowing, `premise: the camera stream flows between every pair while orbiting (${JSON.stringify(cams.map((c) => Object.values(c)))})`); + const worst = Math.max(...rates); + const slowestFps = Math.min(...runs.map((r) => (r.frames * 1000) / r.elapsed)); + // 20/s plus slack for in-flight messages at the cut; a per-frame sender + // would read at its frame rate, which the premise put at 40 or more + h.check( + worst <= 25 && worst < slowestFps * 0.65, + `presence is gated, not per frame: worst ${worst} msgs/s per sender against >= ${Math.round(slowestFps)} fps (all ${JSON.stringify(rates)})` + ); - await h.finish(browser); + // ---- 6. the main thread under the load -------------------------------------------- + const longest = Math.max(...runs.map((r) => r.longest)); + console.log('long tasks while four peers orbit: ' + JSON.stringify(runs.map((r) => ({ n: r.longTasks, longest: Math.round(r.longest) })))); + h.check(longest < 1000, `no peer froze while four orbit and stream presence (longest task ${Math.round(longest)}ms)`); + } catch (error) { + stopSignalServer(signal); + throw error; + } + // `finish` exits the process, so the server we started is stopped BEFORE it — a + // leftover listener on the machine-wide :9001 would be reused by the next lane's run + stopSignalServer(signal); + await h.finish(browserRef); }); From cf1c200d28a65cfb8618785231208160f978bf24 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 10:07:32 +0300 Subject: [PATCH 3/3] [feat] 26-D: a heavy scene gives up shadows before it gives up frames, and a joiner stops starving its own download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 26 section 4, Stage 1, steered by what 26-E measured. - src/lib/qualityGovernorCore.js (PURE, import-free): the decision rule. p95 over 2s above the trigger (or >2 long tasks in 5s) on a HEAVY scene takes one step, held 3s; 10s of p95 under 20ms walks one back. A step up within 20s of a walk down doubles the next recovery hold (flapping), capped at 80s. A 600ms settle window after every change, because the change itself is a hitch (a shadow toggle recompiles every lit material). - src/lib/qualityGovernor.js: the wiring. Frames from sceneBudget's loop, published as a LOCAL qualityOverrides store every consumer reads; never writes a preference, a document or a message. Hidden tab / 26-G pause = no evidence. - THE STEP ORDER IS THE MEASUREMENT'S, not the roadmap's: shadows first (the shadow pass is the second copy of every mesh; calls, not fill, bind a many-object scene), then resolution 85/72%, AO, 61%, the post stack, 50%, the particle cap (Stage 3's third bullet), the presence send gap. Consumers: lightParams + environment through one `shadowsDisabled()` (environment re-asserted the saved preference on every apply and undid the override within a frame — found by the suite), threlte's own dpr (Scene), AO/post filtered in Outline and the composer re-sized on a dpr change, particleRuntime, Scene's camera gap. - The desktop trigger is 35ms, not 33: frames are vsync-quantised, so a steady 30fps reads 33.3-33.4ms and a 33ms trigger would walk it to the bottom of the ladder. - Light scenes are never governed (the 26-G ruling: a slow machine is not an overloaded scene), which also keeps SwiftShader suites untouched. - NOT FIGHTING 26-G: the first step records the size readings (qualityBaseline) and sceneIsHeavy judges by the larger of now and then until full quality returns — else turning shadows off halves the calls and talks the freeze guard out of a scene that is still too heavy. A scene that really shrinks (<70% of the baseline objects) drops it. - THE INGEST DRAW GAP (26-E's biggest finding): while a received batch drains through slow frames (backlog > 50, p95 > 20ms) the renderer draws 4 frames a second, sticky for the drain. MEASURED with the rig, joiner time-to-synced for 1,000 / 2,000 / 3,000 boxes: 8.0s / 112s / ~180s before, 1.8s / 3.4s / 6.2s after, zero long tasks. - UI: a chip beside the object count ("Reduced quality (scene is heavy)" — click to hold, click again for full quality with a 60s snooze), a one-time toast with the same two actions, and Settings > "Reduce quality when the scene is heavy" (LOCAL, default on). Measured end to end in the suite on real frames (Radeon 890M): 3,000 real boxes engage the governor on their own, it takes ONE step (shadows off), draw calls 5,312 -> 2,930, frame p95 50ms -> 33.4ms, and it stops there. Counterfactuals (each broken, red, restored): - sceneIsHeavy ignoring the baseline: 2 red (26-G no longer judges heavy; no pause) - environment re-asserting shadowMap.enabled: 2 red (shadows stay on; real calls 5,312 -> 5,310) - Outline's composer not following dpr: 1 red (composer buffer 1280 -> 1280) - the draw gap early return removed: 1 red (780 render calls/s against 780) - desktop trigger back to 33ms: unit red (steady 30fps read as overloaded) - settle window 0: unit red (the recompile's long tasks take a second step) Suites: perf-governor (new) 39/39; unit qualityGovernor (new) 21, all unit 119/119. Held, green: scene-stress, overload-guard, scene-budget, ingest-gate, scene-poke, object-sync, view-mode, shadows, environment, environment-v2, env-preset-broadcast, scene-post, scene-post-ui, post-play-mode, particles, flow-particle, net-stress, camera-pip, settings-toasts-ux, settings-labels; net-handshake red once on a two-peer join then green on re-run. scene-post-effects 4.5 ("assigning a LUT PUSHES its bytes") is red IDENTICALLY with this diff reverted to HEAD — pre-existing, not chased. svelte-check 352/47 (base 352/47). npm run build green. Co-Authored-By: Claude Opus 5 --- src/App.svelte | 7 +- src/components/Outline.svelte | 35 ++- src/components/Scene.svelte | 23 +- src/components/menu/Controls.svelte | 51 +++- src/components/menu/Settings.svelte | 5 + src/lib/environment.js | 7 +- src/lib/lightParams.js | 20 +- src/lib/overloadGuard.js | 19 +- src/lib/particleRuntime.js | 8 +- src/lib/qualityGovernor.js | 242 ++++++++++++++++++ src/lib/qualityGovernorCore.js | 265 ++++++++++++++++++++ src/lib/sceneBudget.js | 60 +++++ tests/e2e/perf-governor.test.cjs | 370 ++++++++++++++++++++++++++++ tests/unit/qualityGovernor.test.js | 192 +++++++++++++++ 14 files changed, 1274 insertions(+), 30 deletions(-) create mode 100644 src/lib/qualityGovernor.js create mode 100644 src/lib/qualityGovernorCore.js create mode 100644 tests/e2e/perf-governor.test.cjs create mode 100644 tests/unit/qualityGovernor.test.js diff --git a/src/App.svelte b/src/App.svelte index a52b9cec..70a4490e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -454,9 +454,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/wireErrors'), import('./lib/safeStorage'), import('./lib/sceneBudget'), - import('./lib/overloadGuard') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib } + import('./lib/overloadGuard'), + import('./lib/qualityGovernor') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib } }) } }) diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 2abcba88..d60fda87 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -38,6 +38,7 @@ } from 'postprocessing'; import { onMount, onDestroy, untrack } from 'svelte'; import { renderPaused } from '$lib/overloadGuard'; + import { qualityOverrides, ingestDrawGap } from '$lib/qualityGovernor'; // 16-Q4: the camera preview window renders as an inset viewport of THIS renderer import { pipRect, pipTarget, glRect } from '$lib/cameraPip'; import { buildCamera } from '$lib/cameraObjects'; @@ -46,7 +47,7 @@ let outlineEffectSelected: OutlineEffect | null = null; let outlineEffectLocked: OutlineEffect | null = null; - const { scene, renderer, camera, size, autoRender, renderStage } = useThrelte(); + const { scene, renderer, camera, size, autoRender, renderStage, dpr } = useThrelte(); const composer = new EffectComposer(renderer); composer.removeAllPasses(); const renderPass = new RenderPass(scene, camera.current); @@ -215,10 +216,13 @@ // displays, so its output was upsampled and read as a shifted "ghost" of the // shading offset from the objects. The per-kind `resize` hook carries that // lesson in the registry rather than hardcoded here. - const dpr = renderer.getPixelRatio ? renderer.getPixelRatio() : 1; + // 26-D: the governor changes the pixel ratio WITHOUT changing the CSS size, so the + // composer has to follow the dpr too or its targets stay at the old resolution + void $dpr; + const pixelRatio = renderer.getPixelRatio ? renderer.getPixelRatio() : 1; composer.setSize($size.width, $size.height); for (const instance of stackInstances) - instance.def?.resize?.(instance.object, $size.width, $size.height, dpr); + instance.def?.resize?.(instance.object, $size.width, $size.height, pixelRatio); }); // L4: the capability gate now covers the WHOLE stack, not just AO (see // viewMode.postSupported for the three-r185 + Chromium<=150 story, why the @@ -253,14 +257,18 @@ // changes (measured: setting a camera to No files replaced rendered nothing new). void $postStacks; void $lookOverride; + // 26-D: the quality governor's post steps — AO first (the personal chip reads as plain + // shaded, an authored AO entry is dropped), then the whole stack. LOCAL overrides: the + // authored document is never touched, so a peer's look is unchanged + const reduced = $qualityOverrides; const entries = effectivePostStack({ stack: resolvedDoc(POST_SCENE_KEY), cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera) : null), - mode: $viewMode, - localEnabled: $postEnabledLocal, + mode: reduced.aoOff && $viewMode === 'shaded-ao' ? 'shaded' : $viewMode, + localEnabled: $postEnabledLocal && !reduced.postOff, postOk, postWarm - }); + }).filter((entry) => !(reduced.aoOff && entry.kind === 'ao')); const signature = postStackSignature(entries); if (signature === stackSignature) return; stackSignature = signature; @@ -330,9 +338,22 @@ let renderIsPaused = false; const stopPauseWatch = renderPaused.subscribe((value) => (renderIsPaused = !!value)); onDestroy(stopPauseWatch); + // 26-D THE INGEST DRAW GAP (26-E's finding): while a big received scene drains through + // slow frames, draw at most one frame per gap — every object's parse waits for a frame to + // pass, so a joiner redrawing a 2,000-object scene 30 times a second was starving its own + // receive queue (3,000 objects: ~180s drawing, 5.6s not). Never in XR, like the pause. + let drawGapMs = 0; + let lastDrawAt = 0; + const stopGapWatch = ingestDrawGap.subscribe((value) => (drawGapMs = value)); + onDestroy(stopGapWatch); useTask( (delta) => { if (renderIsPaused && !renderer.xr.isPresenting) return; + if (drawGapMs > 0 && !renderer.xr.isPresenting) { + const drawNow = performance.now(); + if (drawNow - lastDrawAt < drawGapMs) return; + lastDrawAt = drawNow; + } // In WebXR the EffectComposer can't be used: its passes render to canvas-sized // targets, not the XR framebuffer, so blitting them mismatches sizes // (GL_INVALID_FRAMEBUFFER_OPERATION) and nothing reaches the headset (dark @@ -460,6 +481,8 @@ return index >= 0 ? 'stack:' + (stackPlan[index]?.kinds ?? []).join('+') : 'other'; }), composerPasses: ((composer as any).passes ?? []).length, + // 26-D: the composer's own buffer, which must follow a governor dpr change + composerBufferWidth: (composer as any).inputBuffer?.width ?? null, outlinedSelected: outlineEffectSelected?.selection.size ?? 0, outlinedLocked: outlineEffectLocked?.selection.size ?? 0, stackPasses: stackPasses.length, diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 1e8a8915..f47c9396 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -1,6 +1,7 @@