diff --git a/scripts/perf-games.cjs b/scripts/perf-games.cjs
index b4d01ff0..cbaee51d 100644
--- a/scripts/perf-games.cjs
+++ b/scripts/perf-games.cjs
@@ -3,7 +3,13 @@
// Games-tab games, measured the same way every time so a before/after means something.
//
// node scripts/perf-games.cjs [--label baseline-1.17] [--only waves,football] [--vr]
-// [--seconds 10] [--throttle 4] [--out
]
+// [--seconds 10] [--throttle 4] [--out ] [--phone]
+//
+// 33 G1 `--phone`: a PHONE profile instead of the desktop one — a 412x915 viewport at
+// deviceScaleFactor 2.625, isMobile + hasTouch (what reproduces a phone's page scale and
+// `(pointer: coarse)`), and CPU x6 unless --throttle says otherwise. The GPU is still this
+// machine's, so the fill-rate half of a phone shows up only as the PIXELS it would push:
+// the table adds the drawing-buffer size and the post passes per frame.
//
// Per game, on a FRESH page against a running dev server (APP_URL, default this lane's
// https://theprototype.app:5263/):
@@ -46,7 +52,8 @@ const flag = (name) => argv.includes('--' + name);
const LABEL = arg('label', 'run');
const SECONDS = Number(arg('seconds', '10'));
-const THROTTLE = Number(arg('throttle', '4'));
+const PHONE = flag('phone');
+const THROTTLE = Number(arg('throttle', PHONE ? '6' : '4'));
const VR = flag('vr');
const PROFILE = flag('profile');
const OUT = arg('out', '/home/deck/.code/lanes-30/after-31/31-perf');
@@ -181,6 +188,16 @@ function readScene() {
});
if (scene?.background?.isTexture) textures.add(scene.background);
if (scene?.environment?.isTexture) textures.add(scene.environment);
+ // 33 G1: what a PHONE's GPU pays for — pixels per frame, and what shades them
+ let physical = 0;
+ let transparent = 0;
+ scene?.traverseVisible?.((o) => {
+ if (!o.isMesh) return;
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
+ if (mats.some((m) => m?.isMeshPhysicalMaterial)) physical++;
+ if (mats.some((m) => m?.transparent)) transparent++;
+ });
+ const buffer = r.getDrawingBufferSize(new s.THREE.Vector2());
let bytes = 0;
for (const t of textures) {
const img = t.image;
@@ -222,7 +239,10 @@ function readScene() {
unculled,
points,
skinned,
- pixelRatio: r.getPixelRatio(),
+ pixelRatio: Math.round(r.getPixelRatio() * 100) / 100,
+ bufferPx: Math.round((buffer.x * buffer.y) / 1000) / 1000,
+ physical,
+ transparent,
heapMB: performance.memory ? Math.round((performance.memory.usedJSHeapSize / 1048576) * 10) / 10 : null,
heapDeltaMB: performance.memory && rec.heap0 != null ? Math.round(((performance.memory.usedJSHeapSize - rec.heap0) / 1048576) * 10) / 10 : null,
lodMeshes: lod ? lod.entries : null,
@@ -238,7 +258,10 @@ async function probeGame(browser, game) {
const missing = (game.modules || []).filter((m) => !zipFor(m.id)).map((m) => m.id);
if (missing.length) return { slug: game.slug, skipped: 'no zip for ' + missing.join(', ') };
- const peer = await h.setupPage(browser, game.slug, { context: { viewport: { width: 1280, height: 720 } } });
+ const context = PHONE
+ ? { viewport: { width: 412, height: 915 }, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true }
+ : { viewport: { width: 1280, height: 720 } };
+ const peer = await h.setupPage(browser, game.slug, { context });
const page = peer.page;
try {
for (const m of game.modules || []) await installZip(page, m.id, zipFor(m.id));
@@ -391,12 +414,12 @@ const over = (v, cap) => (v != null && v > cap ? ' ⚠' : '');
function table(rows) {
const head =
- '| game | calls/frame | tris/frame | geos | tex (MB) | lights (shadow) | cast meshes | meshes / inst / unculled | LOD (coarse) | quality | p50 ms | p95 ms | p99 ms | heap Δ MB | started |\n' +
- '|---|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|---:|---|';
+ '| game | calls/frame | tris/frame | geos | tex (MB) | lights (shadow) | cast meshes | meshes / inst / unculled | LOD (coarse) | quality | renders/frame | Mpx (dpr) | physical / transparent | p50 ms | p95 ms | p99 ms | heap Δ MB | started |\n' +
+ '|---|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---|---:|---:|---:|---:|---|';
const lines = rows.map((r) =>
r.skipped || r.error
- ? `| ${r.slug} | ${r.skipped ? 'SKIP: ' + r.skipped : 'ERROR: ' + r.error} |||||||||||||| |`
- : `| ${r.slug} | ${fmt(r.calls)}${over(r.calls, BUDGET.calls)} | ${fmt(r.triangles)}${over(r.triangles, BUDGET.triangles)} | ${fmt(r.geometries)} | ${fmt(r.textures)} (${fmt(r.textureMB, 1)}) | ${fmt(r.lights)}${over(r.lights, BUDGET.lights)} (${fmt(r.shadowLights)}) | ${fmt(r.castMeshes)} | ${fmt(r.meshes)} / ${fmt(r.instanced)} / ${fmt(r.unculled)} | ${fmt(r.lodMeshes)} (${fmt(r.lodCoarse)}) | ${fmt(r.quality)} | ${fmt(r.p50, 1)} | ${fmt(r.p95, 1)} | ${fmt(r.p99, 1)} | ${fmt(r.heapDeltaMB, 1)} | ${r.started}${r.state ? ' → ' + r.state : ''} |`
+ ? `| ${r.slug} | ${r.skipped ? 'SKIP: ' + r.skipped : 'ERROR: ' + r.error} ||||||||||||||||| |`
+ : `| ${r.slug} | ${fmt(r.calls)}${over(r.calls, BUDGET.calls)} | ${fmt(r.triangles)}${over(r.triangles, BUDGET.triangles)} | ${fmt(r.geometries)} | ${fmt(r.textures)} (${fmt(r.textureMB, 1)}) | ${fmt(r.lights)}${over(r.lights, BUDGET.lights)} (${fmt(r.shadowLights)}) | ${fmt(r.castMeshes)} | ${fmt(r.meshes)} / ${fmt(r.instanced)} / ${fmt(r.unculled)} | ${fmt(r.lodMeshes)} (${fmt(r.lodCoarse)}) | ${fmt(r.quality)} | ${fmt(r.rendersPerFrame, 1)} | ${fmt(r.bufferPx, 2)} (${fmt(r.pixelRatio, 2)}) | ${fmt(r.physical)} / ${fmt(r.transparent)} | ${fmt(r.p50, 1)} | ${fmt(r.p95, 1)} | ${fmt(r.p99, 1)} | ${fmt(r.heapDeltaMB, 1)} | ${r.started}${r.state ? ' → ' + r.state : ''} |`
);
return head + '\n' + lines.join('\n');
}
@@ -440,12 +463,12 @@ function table(rows) {
seconds: SECONDS,
cpuThrottle: THROTTLE,
vr: VR,
- viewport: '1280x720',
+ viewport: PHONE ? '412x915@2.625 (phone)' : '1280x720',
budget: BUDGET
};
const md =
`## perf-games — ${LABEL}${VR ? ' (VR-emulated, walking)' : ''}\n\n` +
- `${meta.at} · ${meta.app} · GPU: ${meta.gpu} · scenes ${meta.scenes} · ${SECONDS} s of Play at CPU x${THROTTLE} · 1280x720\n` +
+ `${meta.at} · ${meta.app} · GPU: ${meta.gpu} · scenes ${meta.scenes} · ${SECONDS} s of Play at CPU x${THROTTLE} · ${meta.viewport}\n` +
`Quest budget: ≤ ${BUDGET.calls} calls, ≤ ${BUDGET.triangles / 1000}k tris, ≤ ${BUDGET.lights} lights (⚠ = over). calls/tris are per display frame, every render() pass summed (shadow pass included).\n\n` +
table(rows) +
'\n';
diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte
index b47e1714..3d355368 100644
--- a/src/components/Scene.svelte
+++ b/src/components/Scene.svelte
@@ -56,7 +56,7 @@
// the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap).
let knifeFrom: number[] | null = null;
import { peerScenes } from '$lib/peerScenes';
- import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls';
+ import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuids, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls';
// 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4)
import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput';
import { gameFeelActive } from '$lib/gameFeel';
@@ -1433,7 +1433,7 @@
// this seam rather than an import (knock.js stays off vrControls' 3500 lines),
// and the two "what am I holding" reads keep a probe off its own carried object.
// A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses
- startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticKnock });
+ startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), ...vrGrabbedUuids()], haptic: hapticKnock });
startClap({ hands: handSnapshot }); // 31: the same hand seam
// 30b: game feel in VR (a frame hook + a trigger hook through vrControls' registries)
startVrGameInput();
diff --git a/src/components/play/VRControls.svelte b/src/components/play/VRControls.svelte
index 4a190adb..915867f0 100644
--- a/src/components/play/VRControls.svelte
+++ b/src/components/play/VRControls.svelte
@@ -38,7 +38,7 @@
// 30b P3: INTERACT walks like a game (collision, gravity, step-up; no fly or teleport
// unless the play block allows them) — a held object does not stop your feet
if (tickVRInteractLocomotion(delta, session)) return;
- if ($vrGrabbedHand === 'left') return; // a left-hand grab owns its stick (100)
+ if ($vrGrabbedHand === 'left' || $vrGrabbedHand === 'both') return; // a left-hand grab owns its stick (100)
if (twoGripStretchActive()) return; // 186: both grips + sticks stretch, not move
// D9: world pan/grab write reference-space offsets themselves, and the
// mesh-edit gestures read the sticks for reel/scale — never also move
diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js
index 7d848674..0e36fe7b 100644
--- a/src/lib/flowRuntime.js
+++ b/src/lib/flowRuntime.js
@@ -1742,8 +1742,9 @@ function stampOfSource(edge, ctx, seen) {
function handleStamp(node, handle, ctx, seen = new Set()) {
if (!ctx?.triggers || !node) return null;
let newest = null;
- for (const edge of edges) {
- if (edge.target !== node.id) continue;
+ // 33 G1: the indexed edges INTO the node — a scan of every edge per call was the Stars
+ // Room's top app frame on a phone profile (24 latches x 3 handles x ~700 edges a tick)
+ for (const edge of edgesInto(edges, node.id)) {
if ((edge.targetHandle ?? null) !== handle) continue;
const stamp = stampOfSource(edge, ctx, seen);
if (typeof stamp === 'number' && (newest === null || stamp > newest)) newest = stamp;
@@ -1881,8 +1882,7 @@ function updateDerivedPulses(ctx) {
function triggerStampFor(nodeId, ctx) {
if (!ctx?.triggers) return null;
let newest = null;
- for (const edge of edges) {
- if (edge.target !== nodeId) continue;
+ for (const edge of edgesInto(edges, nodeId)) {
if (edge.targetHandle && edge.targetHandle !== 'trigger') continue;
// 21-E4: a Delay/Sequence source has NO entry in the trigger log - its stamp is
// derived - so reading the log directly here left those nodes unable to drive
diff --git a/src/lib/physics.js b/src/lib/physics.js
index 14f0268b..9a25353b 100644
--- a/src/lib/physics.js
+++ b/src/lib/physics.js
@@ -210,6 +210,15 @@ function collectParams(group) {
// H1: physics nodes live in ANY graph (scene or per-object documents)
const nodes = allNodes();
const edges = allEdges();
+ // 33 G1: indexed once per call — the nested finds below were O(edges x nodes), and this
+ // runs on EVERY flowGraphs change mid-sim (onGraphChange); measured as the second app
+ // frame of the Stars Room on a phone profile
+ /** @type {Map} */
+ const byId = new Map();
+ for (const n of nodes) if (!byId.has(n.id)) byId.set(n.id, n);
+ /** @type {Set} edge sources that reach an Object Selector */
+ const toSelector = new Set();
+ for (const e of edges) if (byId.get(e.target)?.type === 'objectselector') toSelector.add(e.source);
/** apply one physics node's params onto an object's entry @param {any} source @param {string} uuid */
const applyPhysicsNode = (source, uuid) => {
map[uuid] ??= {};
@@ -243,15 +252,15 @@ function collectParams(group) {
if (source.data?.scale != null) map[uuid].colliderScale = source.data.scale;
if (source.data?.sensor) map[uuid].sensor = true;
const sourceEdge = edges.find((e) => e.target === source.id && e.targetHandle === 'source');
- const sourceNode = sourceEdge ? nodes.find((n) => n.id === sourceEdge.source) : null;
+ const sourceNode = sourceEdge ? byId.get(sourceEdge.source) : null;
const sourceUuid = sourceNode?.type === 'objectselector' ? sourceNode.data?.selected : null;
if (sourceUuid && sourceUuid !== '-None-') map[uuid].colliderSource = sourceUuid;
}
};
edges.forEach((edge) => {
- const source = nodes.find((n) => n.id === edge.source);
+ const source = byId.get(edge.source);
if (!source || !PHYSICS_TYPES.includes(source.type)) return;
- const target = nodes.find((n) => n.id === edge.target);
+ const target = byId.get(edge.target);
if (target?.type !== 'objectselector') return;
const uuid = target.data?.selected;
if (!uuid || uuid === '-None-') return;
@@ -263,10 +272,7 @@ function collectParams(group) {
if (!PHYSICS_TYPES.includes(source.type)) return;
const graph = source.__graph;
if (!graph || graph === SCENE_GRAPH) return;
- const wired = edges.some(
- (e) => e.source === source.id && nodes.find((n) => n.id === e.target)?.type === 'objectselector'
- );
- if (!wired) applyPhysicsNode(source, graph);
+ if (!toSelector.has(source.id)) applyPhysicsNode(source, graph);
});
return map;
}
diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js
index 053afdb5..ac8c17c0 100644
--- a/src/lib/qualityGovernor.js
+++ b/src/lib/qualityGovernor.js
@@ -17,7 +17,7 @@ import {
import { renderPaused } from './overloadGuard';
import { sceneBatchOpen } from '../stores/sceneStore';
import { safeStorage } from './safeStorage';
-import { xrThresholds, XR_START_LEVEL, XR_FRAMEBUFFER_SCALE } from './qualityGovernorCore';
+import { xrThresholds, XR_START_LEVEL, XR_FRAMEBUFFER_SCALE, PHONE_START_LEVEL, isPhoneLike } from './qualityGovernorCore';
import { globalRenderer } from '../stores/sceneStore';
import { lodBias } from './lod';
import { lodBiasFor } from './lodCore';
@@ -89,6 +89,8 @@ let snoozedUntil = 0;
let drawGapEngaged = false;
/** @type {string} */
let lastReason = '';
+/** 33 G1: this device is a phone (decided once at load, see the bottom of the file) */
+let phone = false;
function now() {
return typeof performance !== 'undefined' ? performance.now() : Date.now();
@@ -132,6 +134,9 @@ function context() {
// always worth governing — a missed headset frame is judder, not a slow chart, and the
// light-scene exemption (26-G's ruling) exists for a desktop that is merely slow
if (xr.active) return { metrics, profile: /** @type {'desktop'|'vr'} */ ('vr'), heavy: true };
+ // 33 G1: a phone is fill-bound — a scene "light" by the desktop's call budget still misses
+ // frames there, so it is always worth governing (the headset's rule, desktop thresholds)
+ if (phone) return { metrics, profile: /** @type {'desktop'|'vr'} */ ('desktop'), heavy: true };
const profile = metrics?.profile === 'vr' ? 'vr' : 'desktop';
return { metrics, profile: /** @type {'desktop'|'vr'} */ (profile), heavy: isHeavy(metrics, profile, get(qualityBaseline)) };
}
@@ -396,3 +401,33 @@ export function endXRQuality(r = get(globalRenderer)) {
export function xrQualityDebug() {
return { active: xr.active, hz: xr.hz, raised: xr.raised, minScale: xr.minScale, nextScale: xr.nextScale, floor: governor.floor(), thresholds: governor.thresholds(), level: governor.level() };
}
+
+// ---- 33 G1: A PHONE ------------------------------------------------------------------------
+// Decided once: a coarse pointer, no hover, a short screen side (isPhoneLike). Auto mode then
+// starts at PHONE_START_LEVEL — shadows off, 72 % resolution, AO off — instead of drawing the
+// first seconds of every scene at a phone's full pixel ratio and waiting to be told. With auto
+// quality off nothing changes; a game's pinned Quality preset still wins (applyGameQuality).
+
+/** @returns {boolean} */
+function detectPhone() {
+ try {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
+ return isPhoneLike({
+ coarse: window.matchMedia('(pointer: coarse)').matches,
+ hover: window.matchMedia('(hover: hover)').matches,
+ minSide: Math.min(window.screen?.width ?? 0, window.screen?.height ?? 0)
+ });
+ } catch {
+ return false;
+ }
+}
+phone = detectPhone();
+if (phone && get(autoQuality)) {
+ governor.setLevel(PHONE_START_LEVEL, now());
+ publish(PHONE_START_LEVEL, 'phone: a lighter start');
+}
+
+/** 33 G1: is this device judged as a phone (for the suite and the chip) */
+export function phoneQuality() {
+ return phone;
+}
diff --git a/src/lib/qualityGovernorCore.js b/src/lib/qualityGovernorCore.js
index f935b773..8d17ce1e 100644
--- a/src/lib/qualityGovernorCore.js
+++ b/src/lib/qualityGovernorCore.js
@@ -102,6 +102,30 @@ export function xrThresholds(hz) {
* caster, per eye, and a headset has a third of a desktop's frame time. */
export const XR_START_LEVEL = 1;
+/**
+ * 33 G1 — A PHONE STARTS LIGHTER, AND IS ALWAYS WORTH GOVERNING. The report: "Stars room is
+ * too heavy for mobile users, it lags". The governor never acted on a phone at all: it judges
+ * by the desktop budgets, where a game of 100 draw calls is LIGHT, and a light scene is never
+ * governed (and is walked back to full quality). So a phone drew the Stars Room at its native
+ * pixel ratio (2.6 measured on the phone profile = 2.6 Mpx per pass) through 32 render passes
+ * a frame — shadows, the authored AO, bloom, SMAA. A phone is bound by FILL, not by calls,
+ * so the size of the scene says nothing about whether stepping helps.
+ * The start = shadows off, resolution 72 %, AO off (the first four steps). No floor: a phone
+ * that holds its frames walks back up by the ordinary recovery rule.
+ */
+export const PHONE_START_LEVEL = 4;
+
+/**
+ * A phone or a small tablet: a coarse pointer, no hover, and a short screen side (CSS px).
+ * Pure; the wiring reads the three from matchMedia/screen. A desktop with a touch screen has
+ * hover; a laptop's short side is over the line.
+ * @param {{coarse?: boolean, hover?: boolean, minSide?: number}} d
+ */
+export function isPhoneLike(d) {
+ const side = Number(d?.minSide);
+ return !!d?.coarse && !d?.hover && Number.isFinite(side) && side > 0 && side <= 820;
+}
+
/**
* The XR framebuffer scale, ALWAYS full. 31-perf first carried the resolution half of the ladder
* into the headset through `setFramebufferScaleFactor` (applied to the NEXT session, since three
diff --git a/src/lib/shellPanelDraw.js b/src/lib/shellPanelDraw.js
index edc8fcce..b76abfee 100644
--- a/src/lib/shellPanelDraw.js
+++ b/src/lib/shellPanelDraw.js
@@ -10,6 +10,54 @@
/** The board's stage size while the menu is up, in stage pixels (the panel scales it). */
export const SHELL_STAGE = { x: 0, y: 0, w: 1100, h: 640 };
+/** 33 G5: the labels that did not fit their button while `collectOverflow` ran (a suite reads
+ * them; a label that has to be cut is a layout defect even when the cut keeps it legible)
+ * @type {string[] | null} */
+let overflowSink = null;
+
+/**
+ * Run a draw and return [its result, the labels it had to cut]. @template T
+ * @param {() => T} draw @returns {[T, string[]]}
+ */
+export function collectOverflow(draw) {
+ const outer = overflowSink;
+ /** @type {string[]} */
+ const sink = [];
+ overflowSink = sink;
+ try {
+ return [draw(), sink];
+ } finally {
+ overflowSink = outer;
+ }
+}
+
+/**
+ * 33 G5: set a font that makes `text` FIT `maxW` canvas px — shrink the size down to `min`,
+ * and only then cut it with an ellipsis (and report the cut). Returns the text to draw.
+ * A button label drawn at a fixed size ran past its button on the narrow boards.
+ * @param {CanvasRenderingContext2D} g @param {string} text @param {number} maxW
+ * @param {string | number} weight @param {number} size px @param {number} [min] px
+ */
+export function fitLabel(g, text, maxW, weight, size, min = Math.max(10, size * 0.7)) {
+ let px = size;
+ const font = () => (g.font = `${weight} ${Math.round(px)}px system-ui, sans-serif`);
+ font();
+ while (px > min && g.measureText(text).width > maxW) {
+ px -= Math.max(1, size * 0.04);
+ font();
+ }
+ if (g.measureText(text).width <= maxW) return text;
+ overflowSink?.push(text);
+ let lo = 0;
+ let hi = text.length;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1;
+ if (g.measureText(text.slice(0, mid) + '…').width <= maxW) lo = mid;
+ else hi = mid - 1;
+ }
+ return text.slice(0, lo).trimEnd() + '…';
+}
+
/** @param {CanvasRenderingContext2D} g @param {number} x @param {number} y @param {number} w @param {number} h @param {number} r */
function roundRect(g, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
@@ -56,8 +104,8 @@ export function drawShellPage(g, model, opts) {
g.fillStyle = o.disabled ? 'rgba(243,244,246,0.5)' : '#f3f4f6';
g.textAlign = 'center';
g.textBaseline = 'middle';
- g.font = `600 ${Math.round(26 * k)}px system-ui, sans-serif`;
- g.fillText(label, (x + w / 2) * k, (y + (o.sub ? h * 0.4 : h / 2)) * k);
+ const shown = fitLabel(g, label, (w - 20) * k, 600, 26 * k);
+ g.fillText(shown, (x + w / 2) * k, (y + (o.sub ? h * 0.4 : h / 2)) * k);
if (o.sub) {
g.font = `500 ${Math.round(18 * k)}px system-ui, sans-serif`;
g.fillStyle = '#fbbf24';
diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js
index ac5ae97c..01fe6877 100644
--- a/src/lib/vrControls.js
+++ b/src/lib/vrControls.js
@@ -262,14 +262,27 @@ const raycaster = new THREE.Raycaster();
const tempMatrix = new THREE.Matrix4();
const tempVector = new THREE.Vector3();
-/** @type {any} single-hand grab: { object, index, prevPos, prevQuat, before } */
-let grab = null;
+/** @type {any[]} per-hand grabs, indexed by controller SLOT: each is { object, index, prevPos,
+ * prevQuat, before, ... }. 33 G4: one per hand, so two hands hold two things at once — a single
+ * shared slot let the second hand's grab overwrite the first, and the first hand's piece sat
+ * in its kinematic hold in mid-air until it was grabbed again (the Towers report). */
+const grabs = [null, null];
/** @type {any} two-hand scale: { object, startDistance, startScale, before } */
let scaleGrab = null;
/** 24-A A1: the object a VR hand is holding right now, or null — the knock probe
* skips it (a hand knocking the crate it is carrying would fight its own hold). */
export function vrGrabbedUuid() {
- return grab?.object?.uuid ?? scaleGrab?.object?.uuid ?? null;
+ return grabs[0]?.object?.uuid ?? grabs[1]?.object?.uuid ?? scaleGrab?.object?.uuid ?? null;
+}
+/** 33 G4: EVERY object a VR hand holds right now (both hands may each hold one) */
+export function vrGrabbedUuids() {
+ return [grabs[0]?.object?.uuid, grabs[1]?.object?.uuid, scaleGrab?.object?.uuid].filter(Boolean);
+}
+/** 33 G4: vrGrabbedHand = the hand holding something, or 'both' — the stick gates read it */
+function syncGrabbedHand() {
+ /** @type {any[]} */
+ const hands = grabs.filter(Boolean).map((g) => renderer?.xr?.getController(g.index)?.userData?.handedness ?? null);
+ vrGrabbedHand.set(hands.length === 0 ? null : hands.length === 1 ? hands[0] : 'both');
}
let lastMoveSent = 0;
@@ -1687,7 +1700,12 @@ export function onInputSourcesChange() {
vrEndHandleDrag();
vertexTriggerGrab = null;
}
- grab = null;
+ // a dropped grab RELEASES its body (it used to leave it in its kinematic hold)
+ for (let i = 0; i < grabs.length; i++) {
+ const held = grabs[i];
+ grabs[i] = null;
+ if (held) endGrab(held.object, held.interact ? null : held.before);
+ }
scaleGrab = null;
worldGrab = null;
worldPan = null;
@@ -2188,8 +2206,10 @@ let worldPan = null;
/** 30b P2: test/debug view of what the grips are doing right now */
export function vrGripDebug() {
return {
- grab: grab?.object?.uuid ?? null,
- grabInteract: !!grab?.interact,
+ grab: (grabs[0] ?? grabs[1])?.object?.uuid ?? null,
+ grabInteract: !!(grabs[0] ?? grabs[1])?.interact,
+ grabs: grabs.map((g) => g?.object?.uuid ?? null),
+ scaleGrab: scaleGrab?.object?.uuid ?? null,
worldGrab: !!worldGrab,
worldPan: !!worldPan,
emptyAir: [...emptyAirSqueeze]
@@ -2862,7 +2882,8 @@ function onSqueezeStart(index) {
}
if (get(lockedObjects).find((lock) => lock[1] === object.uuid)) return;
- if (grab && grab.object === object && grab.index !== index) {
+ const other = grabs[1 - index];
+ if (other && other.object === object) {
// 30b P2: a player's second hand does not resize the thing it is holding
if (mode === 'interact') return;
// second hand on the same object -> two-hand scale
@@ -2871,12 +2892,18 @@ function onSqueezeStart(index) {
object,
startDistance: Math.max(distance, 0.05),
startScale: object.scale.clone(),
- before: grab.before
+ before: other.before
};
- grab = null;
- vrGrabbedHand.set(null);
+ grabs[1 - index] = null;
+ syncGrabbedHand();
return;
}
+ // 33 G4: the OTHER hand keeps whatever it holds; only this hand's slot changes
+ const previous = grabs[index];
+ if (previous) {
+ grabs[index] = null;
+ endGrab(previous.object, previous.interact ? null : previous.before);
+ }
const interact = mode === 'interact';
suspendAnimation(object.uuid); // animated objects park at their base while held
@@ -2894,7 +2921,7 @@ function onSqueezeStart(index) {
const parentInv = object.parent.matrixWorld.clone().invert();
const pPos = cPos.clone().applyMatrix4(parentInv);
const pQuat = parentQuat.clone().invert().multiply(cQuat);
- grab = {
+ grabs[index] = {
object,
index,
// 30b P2: a player's hand is RIGID (no gizmo-style move/rotate), and `interact`
@@ -2909,7 +2936,7 @@ function onSqueezeStart(index) {
prevQuat: cQuat,
before: transformStateOf(object)
};
- vrGrabbedHand.set(renderer.xr.getController(index)?.userData?.handedness ?? null);
+ syncGrabbedHand();
// 30b (C4): a grab lands with a `hit` (a gated no-op in Edit, like every pulse)
hapticPattern('hit', renderer.xr.getController(index)?.userData?.handedness ?? undefined);
// 30b P2: a player picking something up is not SELECTING it — no lock broadcast, no
@@ -3040,7 +3067,8 @@ function onSqueezeEnd(index) {
scaleGrab = null;
return;
}
- if (grab && grab.index === index) {
+ const grab = grabs[index];
+ if (grab) {
// K2: a hook may consume the release (drop onto the sleeve = capture a
// slot; the hook restores the object's pose + animation itself, so no
// move commits — but the physics hold must still release)
@@ -3055,14 +3083,14 @@ function onSqueezeEnd(index) {
});
if (consumed) {
import('./physics').then((m) => m.releaseBody(object.uuid));
- grab = null;
- vrGrabbedHand.set(null);
+ grabs[index] = null;
+ syncGrabbedHand();
hapticPulse(0.4, 60);
return;
}
+ grabs[index] = null;
endGrab(object, grab.interact ? null : grab.before);
- grab = null;
- vrGrabbedHand.set(null);
+ syncGrabbedHand();
hapticPulse(0.18, 24);
}
}
@@ -3105,7 +3133,8 @@ export function grabStickAdjust({ length, scale, x, y }) {
};
}
-function updateGrab() {
+/** @param {any} grab one hand's grab (33 G4: each hand updates its own) */
+function updateGrab(grab) {
const controller = renderer.xr.getController(grab.index);
const position = controller.getWorldPosition(new THREE.Vector3());
const quaternion = controller.getWorldQuaternion(new THREE.Quaternion());
@@ -3839,6 +3868,7 @@ export function updateVRControls() {
!get(vrChatPanelOpen) &&
!get(vrKeyboardTarget) &&
get(vrGrabbedHand) !== 'right' &&
+ get(vrGrabbedHand) !== 'both' &&
!vrNavigationSuppressed({ grips: true })
) {
updateTeleport(session);
@@ -3997,7 +4027,7 @@ export function updateVRControls() {
});
if (scaleGrab) updateScaleGrab();
- else if (grab) updateGrab();
+ else for (const held of grabs) if (held) updateGrab(held);
// window grab (111): the hold timer arms, then the grip drives the window
if (windowGrabPending && Date.now() - windowGrabPending.startedAt >= HOLD_MS)
@@ -4308,11 +4338,13 @@ function enterInteractVR() {
worldGrab = null;
worldPan = null;
emptyAirSqueeze[0] = emptyAirSqueeze[1] = false;
- if (grab && !grab.interact) {
- endGrab(grab.object, grab.before);
- grab = null;
- vrGrabbedHand.set(null);
+ for (let i = 0; i < grabs.length; i++) {
+ const held = grabs[i];
+ if (!held || held.interact) continue;
+ grabs[i] = null;
+ endGrab(held.object, held.before);
}
+ syncGrabbedHand();
resetWorldRig();
// at sessionstart no XR frame exists yet (so no viewer pose to move FROM): the spawn
// waits for the first frame that has one (updateVRControls)
diff --git a/src/lib/vrGamePanel.js b/src/lib/vrGamePanel.js
index e6b62cc0..0a952a63 100644
--- a/src/lib/vrGamePanel.js
+++ b/src/lib/vrGamePanel.js
@@ -58,7 +58,7 @@ import {
toggleShellMenu
} from './gameShell';
import { gameSettingValues } from './gameSettings';
-import { drawShellPage, SHELL_STAGE } from './shellPanelDraw';
+import { drawShellPage, SHELL_STAGE, fitLabel, collectOverflow } from './shellPanelDraw';
// 31 K3 G3: Show FPS in the headset (the strip + the wrist)
import { fpsReading, fpsText, noteXrFrame } from './fpsMeter';
import { vignetteFrame } from './comfortVignette';
@@ -75,9 +75,27 @@ const SETTLE_DEG = 2;
const WRIST_W = 0.15;
const STRIP_KEY = 'vr:gameStrip';
-/** the board canvas's pixels per stage pixel: ~1000 px across a metre-wide board, what a
- * headset's ~20 px per degree needs at 1.2 m */
-const SCALE = 1.5;
+/** the board canvas's pixels per stage pixel. 33 X1: 2, i.e. 1250 texels per metre — a Quest
+ * 3's ~25 px per degree at 1.2 m wants ~1190 (`texelRatio`); the 1.5 this was (937 per metre)
+ * magnified every board texel 1.27x, a soft board on a sharp eye buffer */
+const SCALE = 2;
+/** 33 X1: the wrist card is drawn at this many canvas px per layout px (320x300 layout): at
+ * ~0.4 m a Quest 3 wants ~3600 texels per metre and 320 px over 0.15 m was 2133 (0.6x) */
+const WRIST_RES = 2;
+/** a Quest 3's display density at the centre of the lens, px per degree (Meta's figure) */
+export const HEADSET_PPD = 25;
+
+/**
+ * 33 X1: how many canvas texels fall on one headset pixel at `distance` — 1 or more is crisp
+ * (no magnification), under 1 is a texture stretched over more pixels than it has. Pure.
+ * @param {number} canvasPx texels across the surface @param {number} worldW metres across
+ * @param {number} distance metres from the eye @param {number} [ppd]
+ */
+export function texelRatio(canvasPx, worldW, distance, ppd = HEADSET_PPD) {
+ const texelsPerMetre = canvasPx / worldW;
+ const pixelsPerMetre = (ppd * 180) / Math.PI / Math.max(0.05, distance);
+ return texelsPerMetre / pixelsPerMetre;
+}
/* ------------------------------------------------------------ the classification --- */
@@ -345,8 +363,11 @@ export function drawPanel(g, entry, runtime, opts = {}) {
}
} else if (el.kind === 'crosshair' || el.kind === 'minimap' || el.kind === 'debug' || el.kind === 'custom' || el.kind === 'damageflash') {
// a crosshair, a plot, a debug pill and a module's own DOM have no VR form here
+ } else if (text && el.kind === 'button' && !el.wrap) {
+ // 33 G5: a button's label fits the button (the desktop's CSS box never overflows)
+ g.fillText(fitLabel(g, text, w - 10 * k, weight, size), tx, y + h / 2);
} else if (text) {
- // text, timer, button, panel, richtext and anything else that says something
+ // text, timer, panel, richtext and anything else that says something
const lines = el.wrap || el.kind === 'richtext' ? wrapText(g, text.replace(/\[[^\]]*\]/g, ''), w - 8 * k) : [text];
const lh = size * 1.25;
let yy = y + h / 2 - ((lines.length - 1) * lh) / 2;
@@ -377,6 +398,23 @@ export function drawPanel(g, entry, runtime, opts = {}) {
return hits;
}
+/**
+ * 33 G5: where the footer's n buttons go on a board W canvas px wide — "Menu" and "Top strip"
+ * ran OFF the board: three 240-stage-px buttons need 760 while a cropped game menu can be
+ * 720 wide, so the row hung past both edges. The buttons keep their natural width when it
+ * fits and share the board (inside a side margin) when it does not. Pure; exported.
+ * @param {number} W @param {number} k canvas px per stage px @param {number} n
+ * @returns {{x: number, w: number}[]}
+ */
+export function footerLayout(W, k, n) {
+ const margin = 32 * k;
+ const gap = 20 * k;
+ const natural = (n > 2 ? 240 : 300) * k;
+ const bw = Math.max(0, Math.min(natural, (W - 2 * margin - (n - 1) * gap) / n));
+ const x0 = (W - (n * bw + (n - 1) * gap)) / 2;
+ return Array.from({ length: n }, (_, i) => ({ x: x0 + i * (bw + gap), w: bw }));
+}
+
/**
* The footer: OUR buttons, always there — 31 K3's Menu (while a game offers the pause
* menu), Edit mode and Top strip. @param {CanvasRenderingContext2D} g @param {number} fy
@@ -392,12 +430,10 @@ function drawFooter(g, fy, k, opts, hits) {
{ id: 'edit', label: 'Edit mode' },
{ id: 'strip', label: opts.strip === false ? 'Top strip: off' : 'Top strip: on' }
];
- const bw = (buttons.length > 2 ? 240 : 300) * k;
const bh = 60 * k;
- const gap = 20 * k;
- const x0 = (W - (buttons.length * bw + (buttons.length - 1) * gap)) / 2;
+ const slots = footerLayout(W, k, buttons.length);
buttons.forEach((b, i) => {
- const bx = x0 + i * (bw + gap);
+ const { x: bx, w: bw } = slots[i];
const by = fy + (FOOTER_H * k - bh) / 2;
roundRect(g, bx, by, bw, bh, 12 * k);
g.fillStyle = opts.hover === 'footer:' + b.id ? '#1e4a63' : b.id === 'edit' ? '#1f2937' : '#111827';
@@ -406,10 +442,9 @@ function drawFooter(g, fy, k, opts, hits) {
g.strokeStyle = opts.hover === 'footer:' + b.id ? '#5fd0ff' : 'rgba(148,163,184,0.6)';
g.stroke();
g.fillStyle = '#f3f4f6';
- g.font = `600 ${26 * k}px system-ui, sans-serif`;
g.textAlign = 'center';
g.textBaseline = 'middle';
- g.fillText(b.label, bx + bw / 2, by + bh / 2);
+ g.fillText(fitLabel(g, b.label, bw - 24 * k, 600, 26 * k), bx + bw / 2, by + bh / 2);
hits.push({ id: 'footer:' + b.id, key: '', kind: 'footer', footer: b.id, x: bx, y: by, w: bw, h: bh });
});
}
@@ -493,12 +528,25 @@ function wrapText(g, text, max) {
* A compact card of lines (+ optional footer buttons) — the wrist, the top strip and the
* banner are all this. Returns the pressable rects.
* @param {CanvasRenderingContext2D} g @param {string[]} lines
- * @param {{title?: string, row?: boolean, buttons?: boolean, strip?: boolean, hover?: string | null, big?: boolean, color?: string, menu?: 'open' | 'available' | null}} [opts]
+ * @param {{title?: string, row?: boolean, buttons?: boolean, strip?: boolean, hover?: string | null, big?: boolean, color?: string, menu?: 'open' | 'available' | null, res?: number}} [opts]
* @returns {HitRect[]}
*/
export function drawCard(g, lines, opts = {}) {
- const W = g.canvas.width;
- const H = g.canvas.height;
+ // 33 X1: `res` draws the same LAYOUT at res canvas px per layout px (the wrist card), hits
+ // returned in canvas px like every other surface
+ const res = Math.max(1, Number(opts.res) || 1);
+ g.setTransform(res, 0, 0, res, 0, 0);
+ try {
+ const hits = drawCardAt(g, g.canvas.width / res, g.canvas.height / res, lines, opts);
+ return res === 1 ? hits : hits.map((r) => ({ ...r, x: r.x * res, y: r.y * res, w: r.w * res, h: r.h * res }));
+ } finally {
+ g.setTransform(1, 0, 0, 1, 0, 0);
+ }
+}
+
+/** drawCard in layout units W x H @param {CanvasRenderingContext2D} g @param {number} W
+ * @param {number} H @param {string[]} lines @param {any} opts @returns {HitRect[]} */
+function drawCardAt(g, W, H, lines, opts) {
g.clearRect(0, 0, W, H);
roundRect(g, 0, 0, W, H, Math.min(24, H / 4));
g.fillStyle = 'rgba(12, 16, 26, 0.86)';
@@ -562,9 +610,8 @@ export function drawCard(g, lines, opts = {}) {
g.strokeStyle = opts.hover === 'footer:' + b.id ? '#5fd0ff' : 'rgba(148,163,184,0.6)';
g.stroke();
g.fillStyle = '#f3f4f6';
- g.font = `600 ${labels.length > 2 ? 17 : 22}px system-ui, sans-serif`;
g.textAlign = 'center';
- g.fillText(b.label, bx + bw / 2, by + (footer - 22) / 2);
+ g.fillText(fitLabel(g, b.label, bw - 12, 600, labels.length > 2 ? 18 : 22, 12), bx + bw / 2, by + (footer - 22) / 2);
hits.push({ id: 'footer:' + b.id, key: '', kind: 'footer', footer: b.id, x: bx, y: by, w: bw, h: footer - 22 });
});
}
@@ -575,7 +622,7 @@ export function drawCard(g, lines, opts = {}) {
/**
* @typedef {{name: string, mesh: THREE.Mesh, canvas: HTMLCanvasElement, g: CanvasRenderingContext2D,
- * texture: THREE.CanvasTexture, w: number, h: number, hits: HitRect[], sig: string}} Surface
+ * texture: THREE.CanvasTexture, w: number, h: number, hits: HitRect[], sig: string, overflow?: string[]}} Surface
*/
/** @type {Record} */
@@ -726,9 +773,11 @@ export function vrGamePanelFrame(opts = {}) {
if (sig !== board.sig) {
board.sig = sig;
resizeSurface(board, Math.round(crop.w * SCALE), Math.round((crop.h + FOOTER_H) * SCALE), crop.w * METRES_PER_PX);
- board.hits = shellOpen
- ? drawShellPanel(board.g, { hover: hover[0] ?? hover[1], strip })
- : drawPanel(board.g, entry, runtime, { hover: hover[0] ?? hover[1], strip, crop, menu: menuOffer });
+ [board.hits, board.overflow] = collectOverflow(() =>
+ shellOpen
+ ? drawShellPanel(board.g, { hover: hover[0] ?? hover[1], strip })
+ : drawPanel(board.g, entry, runtime, { hover: hover[0] ?? hover[1], strip, crop, menu: menuOffer })
+ );
board.texture.needsUpdate = true;
}
const headYaw = yawOf(head.quaternion);
@@ -747,20 +796,23 @@ export function vrGamePanelFrame(opts = {}) {
);
// 31 K3 G3: this game's Show FPS puts the counter FIRST on the strip and the wrist
if (live && get(gameSettingValues).showFps) lines.unshift(fpsText(get(fpsReading)).main);
- const wrist = surface('vr-game-wrist', 320, 300, WRIST_W);
+ const wrist = surface('vr-game-wrist', 320 * WRIST_RES, 300 * WRIST_RES, WRIST_W);
const leftHand = opts.hands?.[0] ?? null;
if (live && leftHand) {
const sig = JSON.stringify([lines, strip, hover, menuOffer, shellOpen]);
if (sig !== wrist.sig) {
// a line count change re-sizes the card
wrist.sig = sig;
- wrist.hits = drawCard(wrist.g, lines.length ? lines : ['No score yet'], {
- title: 'Game',
- buttons: true,
- strip,
- hover: hover[0] ?? hover[1],
- menu: menuOffer ? (shellOpen ? 'open' : 'available') : null
- });
+ [wrist.hits, wrist.overflow] = collectOverflow(() =>
+ drawCard(wrist.g, lines.length ? lines : ['No score yet'], {
+ title: 'Game',
+ buttons: true,
+ strip,
+ hover: hover[0] ?? hover[1],
+ menu: menuOffer ? (shellOpen ? 'open' : 'available') : null,
+ res: WRIST_RES
+ })
+ );
wrist.texture.needsUpdate = true;
}
// up the forearm, facing up off it — clear of 30b-vr-modes' mode label, which sits on
@@ -976,12 +1028,14 @@ export function pokeFrame(index, tip) {
return pressed;
}
-/** @returns {{presses: number, pokes: number, lastPress: string | null, edits: number, hover: (string | null)[], strip: boolean, hits: Record}} */
+/** @returns {{presses: number, pokes: number, lastPress: string | null, edits: number, hover: (string | null)[], strip: boolean, hits: Record, overflow: string[]}} */
export function vrGamePanelDebug() {
/** @type {Record} */
const hits = {};
for (const [name, s] of Object.entries(surfaces)) hits[name] = s.hits.map((h) => ({ ...h }));
- return { ...debug, hover: [...hover], strip: stripOn(), hits };
+ // 33 G5: the labels the visible surfaces had to cut to fit (a layout defect)
+ const overflow = Object.values(surfaces).flatMap((s) => (s.mesh.visible ? (s.overflow ?? []).map((t) => s.name + ': ' + t) : []));
+ return { ...debug, hover: [...hover], strip: stripOn(), hits, overflow };
}
/** the surfaces' meshes (suites read visibility, poses and canvases) @param {string} name */
diff --git a/tests/e2e/game-menu-layout.test.cjs b/tests/e2e/game-menu-layout.test.cjs
new file mode 100644
index 00000000..b9498d08
--- /dev/null
+++ b/tests/e2e/game-menu-layout.test.cjs
@@ -0,0 +1,243 @@
+// 33 G5: EVERY GAME MENU IS LAID OUT. The Quest report: "When game menu/options shows 'Menu'
+// and 'Top strip' buttons are too close or out of menu table/borders (gui thing, fix
+// placement). Ensure the rest game menus also placed correctly."
+//
+// An automated BOUNDS AUDIT over the seven Games-tab games, loaded from their real .tpscene
+// files with their real module zips:
+// · desktop — the game's own HUD screens in every game state (menu / playing / paused /
+// over) and the pause menu's pages (main, settings, levels, how to play): every control
+// inside its card / the viewport, no control sticking out of a HUD panel it sits on, and
+// no two controls closer than a minimum gap;
+// · VR (emulated) — the game panel's board for every state, the pause menu's pages on the
+// board, and the wrist card: every pressable rect inside its canvas with a margin, no two
+// closer than the gap, and no label wider than its button (the drawing code reports
+// those itself, `vrGamePanelDebug().overflow`).
+// Shots of every board and page land in EVIDENCE_DIR when set.
+//
+// Scenes: SHELL_SCENES_DIR=/scene.tpscene>, else the sibling scenes checkout.
+// A game whose scene or zip is missing is SKIPPED (named), never failed.
+const h = require('./helpers.cjs');
+const xr = require('./fakeXR.cjs');
+const fs = require('fs');
+const path = require('path');
+
+const SCENES_REPO = [path.resolve(__dirname, '../../../theprototype.app-scenes'), path.resolve(__dirname, '../../../scenes')].find((p) => fs.existsSync(p));
+const sceneOf = (slug) => {
+ const dir = process.env.SHELL_SCENES_DIR;
+ const p = dir ? path.join(dir, slug, 'scene.tpscene') : SCENES_REPO && path.join(SCENES_REPO, 'games', slug, 'scene.tpscene');
+ return p && fs.existsSync(p) ? p : null;
+};
+const GAMES = [
+ { slug: 'towers', modules: [] },
+ { slug: 'stars-room', modules: [] },
+ { slug: 'football', modules: ['football'] },
+ { slug: 'jam-room', modules: ['music-lab', 'music-fx'] },
+ { slug: 'dungeon-realms', modules: ['dungeon', 'dungeon-realms'] },
+ { slug: 'untangle', modules: ['untangle'] },
+ { slug: 'waves', modules: ['health', 'waves'] }
+].filter((g) => !process.env.ONLY || process.env.ONLY.split(',').includes(g.slug));
+const EVIDENCE = process.env.EVIDENCE_DIR || '';
+const DESKTOP_GAP = 4; // px between two controls
+const VR_MARGIN = 10; // canvas px from the board edge
+const VR_GAP = 8; // canvas px between two pressables
+
+/** the separation of two rects (negative = they overlap on both axes) */
+const separation = (a, b) => Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w), a.y - (b.y + b.h), b.y - (a.y + a.h));
+const inside = (r, box, margin = 0) =>
+ r.x >= box.x + margin - 0.5 && r.y >= box.y + margin - 0.5 && r.x + r.w <= box.x + box.w - margin + 0.5 && r.y + r.h <= box.y + box.h - margin + 0.5;
+const fmt = (r) => `${r.id ?? ''}@${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}x${Math.round(r.h)}`;
+
+/** every bounds problem of a set of controls in a box: [] when the layout is clean */
+function audit(items, box, { margin = 0, gap = 0 } = {}) {
+ const out = [];
+ for (const r of items) if (!inside(r, box, margin)) out.push('outside: ' + fmt(r) + ' box ' + fmt(box));
+ for (let i = 0; i < items.length; i++)
+ for (let j = i + 1; j < items.length; j++) {
+ const s = separation(items[i], items[j]);
+ if (s < gap) out.push(`too close (${s.toFixed(1)} px): ${fmt(items[i])} / ${fmt(items[j])}`);
+ }
+ return out;
+}
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } });
+ const page = A.page;
+ const installed = new Set();
+ for (const id of [...new Set(GAMES.flatMap((g) => g.modules))]) if (await h.installModule(A, id)) installed.add(id);
+ await page.evaluate(() => window.__stores.modulesOpen.set(false));
+ await page.waitForTimeout(300);
+
+ /** the desktop HUD on show: pressables + the panels they sit on */
+ const hudRects = () =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ const rect = (el) => {
+ const r = el.getBoundingClientRect();
+ return { x: r.left, y: r.top, w: r.width, h: r.height };
+ };
+ const slots = [...document.querySelectorAll('#hud-layer .hud-slot')].filter((el) => el.getBoundingClientRect().width > 0);
+ const pressable = slots
+ .filter((el) => s.hudKinds.isInteractiveKind(el.dataset.hudKind) && getComputedStyle(el).visibility !== 'hidden')
+ .map((el) => ({ id: el.dataset.hudId, ...rect(el) }));
+ const panels = slots.filter((el) => el.dataset.hudKind === 'panel').map((el) => ({ id: el.dataset.hudId, ...rect(el) }));
+ return { pressable, panels, vw: innerWidth, vh: innerHeight };
+ });
+
+ /** the pause card's controls (a SCROLLING card counts its scroll height) */
+ const shellRects = () =>
+ page.evaluate(() => {
+ const card = document.querySelector('#game-shell-menu .gs-card');
+ if (!card) return null;
+ const c = card.getBoundingClientRect();
+ const box = { x: c.left, y: c.top, w: c.width, h: Math.max(c.height, card.scrollHeight) };
+ const controls = [...card.querySelectorAll('button, input')]
+ .filter((el) => el.getBoundingClientRect().width > 0)
+ .map((el) => {
+ const r = el.getBoundingClientRect();
+ return { id: el.dataset.shellItem || el.dataset.shellLevel || el.getAttribute('aria-label') || el.className, x: r.left, y: r.top + card.scrollTop, w: r.width, h: r.height };
+ });
+ // a choice row's ‹ › and a range sit IN one row: audit gaps between ROWS, and
+ // within a row only that nothing overlaps
+ return { box, controls, overflowX: card.scrollWidth > card.clientWidth + 1 };
+ });
+
+ /** draw the VR surfaces once and read their hits (+ a PNG of the board) */
+ const vrFrame = (shot) =>
+ page.evaluate(async (shot) => {
+ const s = window.__stores;
+ const T = s.THREE;
+ const k = s.gameKit.vrGamePanel;
+ const head = { position: new T.Vector3(0, 1.6, 0), quaternion: new T.Quaternion() };
+ // the left wrist turned toward the face so the card is drawn
+ const left = { position: new T.Vector3(-0.15, 1.2, -0.35), quaternion: new T.Quaternion().setFromEuler(new T.Euler(-1.1, 0, 0)) };
+ k.vrGamePanelFrame({ head, hands: [left, null], force: true });
+ const d = k.vrGamePanelDebug();
+ const board = k.vrGameSurface('vr-game-panel');
+ const wrist = k.vrGameSurface('vr-game-wrist');
+ return {
+ board: board?.mesh.visible ? { w: board.canvas.width, h: board.canvas.height, hits: d.hits['vr-game-panel'], png: shot ? board.canvas.toDataURL('image/png') : null } : null,
+ wrist: wrist ? { w: wrist.canvas.width, h: wrist.canvas.height, hits: d.hits['vr-game-wrist'], png: shot ? wrist.canvas.toDataURL('image/png') : null } : null,
+ overflow: d.overflow ?? []
+ };
+ }, shot);
+ const save = (name, dataUrl) => {
+ if (!EVIDENCE || !dataUrl) return;
+ fs.writeFileSync(path.join(EVIDENCE, name), Buffer.from(dataUrl.split(',')[1], 'base64'));
+ };
+ const setState = (state) => page.evaluate((st) => window.__stores.gameState.setGameState(st), state);
+
+ for (const game of GAMES) {
+ const file = sceneOf(game.slug);
+ const missing = game.modules.filter((m) => !installed.has(m));
+ if (!file || missing.length) {
+ console.log('SKIP ' + game.slug + ': ' + (file ? 'no ' + missing.join(', ') + '.zip' : 'no scene.tpscene'));
+ continue;
+ }
+ console.log('\n=== ' + game.slug + ' ===');
+ const bytes = Array.from(fs.readFileSync(file));
+ await page.evaluate(async (arr) => {
+ const s = window.__stores;
+ s.templatesModalOpen.set(false);
+ const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer);
+ await s.sessions.applySession(payload, { backup: false });
+ }, bytes);
+ await page.waitForTimeout(3000);
+
+ // ================================================= desktop: the game's own screens
+ await page.evaluate(() => window.__stores.playMode.requestPlay());
+ await page.waitForTimeout(800);
+ const desk = [];
+ for (const state of ['menu', 'playing', 'paused', 'over']) {
+ await setState(state);
+ await page.waitForTimeout(400);
+ const r = await hudRects();
+ const view = { x: 0, y: 0, w: r.vw, h: r.vh };
+ for (const p of audit(r.pressable, view, { margin: 2, gap: DESKTOP_GAP })) desk.push(state + ': ' + p);
+ // a control that sits ON a panel must sit INSIDE it (no half-in, half-out)
+ for (const b of r.pressable)
+ for (const p of r.panels) {
+ const touching = separation(b, p) < 0;
+ if (touching && !inside(b, p)) desk.push(`${state}: ${b.id} sticks out of panel ${p.id}`);
+ }
+ if (EVIDENCE) await page.screenshot({ path: path.join(EVIDENCE, `hud-${game.slug}-${state}.png`) });
+ }
+ h.check(desk.length === 0, `${game.slug} desktop HUD: every control inside the view and its panel, ${DESKTOP_GAP}px apart${desk.length ? ' — ' + desk.slice(0, 4).join(' | ') : ''}`);
+
+ // ================================================= desktop: the pause menu's pages
+ await setState('playing');
+ await page.evaluate(() => window.__stores.gameKit.gameShell.openShellMenu('main'));
+ await page.waitForTimeout(300);
+ const pages = await page.evaluate(() => {
+ const s = window.__stores;
+ let lv;
+ s.gameKit.gameShell.gameLevels.subscribe((v) => (lv = v))();
+ return ['main', 'settings', ...(lv?.list?.length ? ['levels'] : []), 'help'];
+ });
+ const shell = [];
+ for (const pg of pages) {
+ await page.evaluate((pg) => window.__stores.gameKit.gameShell.openShellMenu(pg), pg);
+ await page.waitForTimeout(250);
+ const r = await shellRects();
+ if (!r) {
+ shell.push(pg + ': no card');
+ continue;
+ }
+ if (r.overflowX) shell.push(pg + ': the card scrolls sideways');
+ for (const p of audit(r.controls, r.box, { margin: 4, gap: pg === 'settings' ? 0 : DESKTOP_GAP })) shell.push(pg + ': ' + p);
+ if (EVIDENCE) await page.screenshot({ path: path.join(EVIDENCE, `shell-${game.slug}-${pg}.png`) });
+ }
+ h.check(shell.length === 0, `${game.slug} desktop pause menu (${pages.join(', ')}): every control inside the card, none overlapping${shell.length ? ' — ' + shell.slice(0, 4).join(' | ') : ''}`);
+ await page.evaluate(() => window.__stores.gameKit.gameShell.closeShellMenu());
+ await h.leavePlay(page);
+ await page.waitForTimeout(400);
+
+ // ================================================= VR: the board, the wrist
+ await page.evaluate(() => {
+ window.__stores.isVRMode.set(true);
+ window.__stores.objectActions.setEditorMode('interact');
+ });
+ await xr.install(page);
+ await xr.setOn(page, true);
+ await page.waitForTimeout(300);
+ const vr = [];
+ let boards = 0;
+ for (const state of ['menu', 'playing', 'paused', 'over']) {
+ await setState(state);
+ await page.waitForTimeout(250);
+ const f = await vrFrame(!!EVIDENCE);
+ if (f.board) {
+ boards += 1;
+ for (const p of audit(f.board.hits, { x: 0, y: 0, w: f.board.w, h: f.board.h }, { margin: VR_MARGIN, gap: VR_GAP })) vr.push('board ' + state + ': ' + p);
+ save(`vr-board-${game.slug}-${state}.png`, f.board.png);
+ }
+ if (f.wrist?.hits?.length) for (const p of audit(f.wrist.hits, { x: 0, y: 0, w: f.wrist.w, h: f.wrist.h }, { margin: 6, gap: 6 })) vr.push('wrist ' + state + ': ' + p);
+ if (state === 'menu') save(`vr-wrist-${game.slug}.png`, f.wrist?.png);
+ for (const o of f.overflow) vr.push(`${state}: label wider than its button: ${o}`);
+ }
+ h.check(boards > 0, `${game.slug} VR: the board showed at least one menu screen (${boards})`);
+ await setState('playing');
+ for (const pg of pages) {
+ await page.evaluate((pg) => window.__stores.gameKit.gameShell.openShellMenu(pg), pg);
+ await page.waitForTimeout(150);
+ const f = await vrFrame(!!EVIDENCE);
+ if (!f.board) {
+ vr.push('shell ' + pg + ': no board');
+ continue;
+ }
+ for (const p of audit(f.board.hits, { x: 0, y: 0, w: f.board.w, h: f.board.h }, { margin: VR_MARGIN, gap: VR_GAP })) vr.push('shell ' + pg + ': ' + p);
+ for (const o of f.overflow) vr.push(`shell ${pg}: label wider than its button: ${o}`);
+ save(`vr-shell-${game.slug}-${pg}.png`, f.board.png);
+ }
+ await page.evaluate(() => window.__stores.gameKit.gameShell.closeShellMenu());
+ h.check(vr.length === 0, `${game.slug} VR: every pressable inside its board/wrist with a ${VR_MARGIN}px margin, ${VR_GAP}px apart, labels fit${vr.length ? ' — ' + vr.slice(0, 5).join(' | ') : ''}`);
+ await xr.uninstall(page);
+ await page.evaluate(() => {
+ window.__stores.isVRMode.set(false);
+ window.__stores.objectActions.setEditorMode('edit');
+ window.__stores.gameState.setGameState('menu');
+ });
+ await page.waitForTimeout(300);
+ }
+ await h.finish(browser);
+});
diff --git a/tests/e2e/quality-phone.test.cjs b/tests/e2e/quality-phone.test.cjs
new file mode 100644
index 00000000..10bddcad
--- /dev/null
+++ b/tests/e2e/quality-phone.test.cjs
@@ -0,0 +1,91 @@
+// 33 G1: A PHONE STARTS LIGHTER. The report: "Stars room is too heavy for mobile users, it
+// lags". The quality governor never acted on a phone: it judged by the desktop budgets, where a
+// 100-call game is LIGHT, and a light scene is never governed — so a phone drew every scene at
+// its full pixel ratio through shadows, the authored AO, bloom and SMAA (32 passes a frame on
+// the Stars Room, measured with scripts/perf-games.cjs --phone).
+//
+// A phone-profile page (412x915 @2.625, isMobile + hasTouch = a coarse pointer, no hover)
+// against a desktop page: the phone boots at PHONE_START_LEVEL (shadows off, 72 % resolution,
+// AO off), its drawing buffer shrinks with it, and slow frames on a LIGHT scene still step it
+// further — which the desktop correctly refuses. Then the real Stars Room on the phone.
+const h = require('./helpers.cjs');
+const fs = require('fs');
+const path = require('path');
+
+const SCENES = process.env.SHELL_SCENES_DIR || [path.resolve(__dirname, '../../../scenes/games')].find((p) => fs.existsSync(p));
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const P = await h.setupPage(browser, 'phone', { context: { viewport: { width: 412, height: 915 }, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true } });
+ const D = await h.setupPage(browser, 'desk', { context: { viewport: { width: 1280, height: 720 } } });
+
+ const read = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; };
+ const r = get(s.globalRenderer);
+ return {
+ phone: s.qualityGovernor.phoneQuality(),
+ state: get(s.qualityGovernor.qualityState),
+ overrides: get(s.qualityGovernor.qualityOverrides),
+ dpr: window.devicePixelRatio,
+ pixelRatio: r.getPixelRatio(),
+ shadowMap: r.shadowMap.enabled,
+ coarse: matchMedia('(pointer: coarse)').matches
+ };
+ });
+ await P.page.waitForTimeout(1500);
+ const p0 = await read(P.page);
+ const d0 = await read(D.page);
+ h.check(p0.coarse && p0.dpr > 2.5, `premise: the phone page has a coarse pointer at dpr ${p0.dpr}`);
+ h.check(p0.phone === true && d0.phone === false, `the phone is judged a phone, the desktop is not (${p0.phone}/${d0.phone})`);
+ h.check(p0.state.level === 4 && /phone/.test(p0.state.reason), `the phone boots at the phone start level (${p0.state.level}, "${p0.state.reason}")`);
+ h.check(p0.overrides.shadowsOff && p0.overrides.aoOff && p0.overrides.dprScale === 0.72 && !p0.overrides.postOff, `phone start = shadows off, AO off, 72 % resolution, the look kept (${JSON.stringify(p0.overrides)})`);
+ h.check(Math.abs(p0.pixelRatio - p0.dpr * 0.72) < 0.02, `the phone draws at 72 % of its pixel ratio (${p0.pixelRatio.toFixed(2)} of ${p0.dpr})`);
+ h.check(d0.state.level === 0 && d0.pixelRatio === d0.dpr, `the desktop boots at full quality (${d0.state.level}, dpr ${d0.pixelRatio})`);
+
+ // ---- slow frames on a LIGHT scene: the phone steps further, the desktop never does
+ const slowLight = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ s.sceneBudget.stopSceneMetrics();
+ s.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 30, meshes: 30, triangles: 6000, calls: 100 });
+ const q = s.qualityGovernor.governorForTest;
+ let t = 1e7;
+ // the start level is the level we have: let its hold pass, then 3 s of 50 ms frames
+ for (let i = 0; i < 80; i++) q.frame(50, (t += 50));
+ const out = s.qualityGovernor.decideNow(t);
+ return { moved: out.moved, level: out.level };
+ });
+ const ps = await slowLight(P.page);
+ h.check(ps.moved === 'up' && ps.level === 5, `a phone missing frames on a light scene steps further (${JSON.stringify(ps)})`);
+ const ds = await slowLight(D.page);
+ h.check(ds.moved !== 'up' && ds.level === 0, `the desktop never governs a light scene (${JSON.stringify(ds)})`);
+ await D.ctx.close();
+
+ // ---- the real Stars Room on the phone: AO is gone from the stack, shadows off
+ const file = SCENES && path.join(SCENES, 'stars-room', 'scene.tpscene');
+ if (file && fs.existsSync(file)) {
+ await P.page.evaluate(() => {
+ const s = window.__stores;
+ s.qualityGovernor.governorForTest.setLevel(4);
+ });
+ await P.page.evaluate(async (arr) => {
+ const s = window.__stores;
+ s.templatesModalOpen.set(false);
+ const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer);
+ await s.sessions.applySession(payload, { backup: false });
+ }, Array.from(fs.readFileSync(file)));
+ await P.page.waitForTimeout(3000);
+ const stars = await P.page.evaluate(() => {
+ const s = window.__stores;
+ const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; };
+ const r = get(s.globalRenderer);
+ const post = window.__postDebug ? window.__postDebug() : null;
+ return { kinds: post?.kinds ?? null, shadowMap: r.shadowMap.enabled, level: get(s.qualityGovernor.qualityState).level };
+ });
+ h.check(stars.level >= 4 && stars.shadowMap === false, `Stars Room on the phone: level ${stars.level}, shadows off (${stars.shadowMap})`);
+ h.check(Array.isArray(stars.kinds) && !stars.kinds.includes('ao'), `Stars Room on the phone: the authored AO is off, the rest of the look stays (${JSON.stringify(stars.kinds)})`);
+ } else console.log('SKIP the Stars Room section: no scene at ' + SCENES);
+ await h.finish(browser);
+});
diff --git a/tests/e2e/vr-panel-sharpness-all.test.cjs b/tests/e2e/vr-panel-sharpness-all.test.cjs
new file mode 100644
index 00000000..5cc56c71
--- /dev/null
+++ b/tests/e2e/vr-panel-sharpness-all.test.cjs
@@ -0,0 +1,196 @@
+// 33 X1: EVERY VR PANEL IS CRISP. 1.18 fixed the eye buffer (vr-panel-sharpness: a headset entry
+// always gets a FULL framebuffer). This extends the probe to every canvas-drawn panel: text drawn
+// into a canvas is only as sharp as the canvas, so a panel whose texture is STRETCHED over more
+// headset pixels than it has texels reads soft on a perfectly sharp eye buffer.
+//
+// THE PROBE: for each surface the VR game UI draws — the game board (a game's own menu), the
+// pause menu on the board, the left-wrist card, the top strip and the announce banner — at the
+// pose it is really drawn at (vrGamePanelFrame from a standing head, the wrist turned to read),
+// texels per metre (canvas px / world width) against a Quest 3's display density at that
+// distance (~25 px per degree): `texelRatio` >= 1 is crisp. MEASURED before this lane: board
+// 0.78, wrist 0.60 (magnified 1.3x / 1.7x); strip and banner already >= 1.
+// A RENDERED check backs the number: the wrist card drawn from the eye at its real distance has
+// at least as much text edge energy (mean squared Laplacian) as the same card at half its texels.
+// Panels drawn with troika Text (radial menu, objects/properties panels, keyboard) are signed
+// distance fields — resolution independent, crisp at any eye buffer — so only canvases are here.
+const h = require('./helpers.cjs');
+
+const GAME = {
+ active: '',
+ screens: [
+ {
+ id: 'menu',
+ name: 'Menu',
+ showWhile: 'menu',
+ input: 'menu',
+ elements: [
+ { id: 'title', kind: 'text', anchor: 'top-center', x: 0, y: 80, w: 600, h: 90, label: 'SHARP TEXT', style: { size: 64, align: 'center' } },
+ { id: 'start', kind: 'button', anchor: 'center', x: 0, y: 0, w: 320, h: 80, label: 'Start', style: { size: 32 } }
+ ]
+ },
+ {
+ id: 'hud',
+ name: 'Round',
+ showWhile: 'playing',
+ input: 'game',
+ elements: [
+ { id: 'score', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 280, h: 30, label: 'Score 12', style: { size: 20 } },
+ { id: 'time', kind: 'text', anchor: 'top-center', x: 0, y: 48, w: 280, h: 30, label: '42s left', style: { size: 16 } }
+ ]
+ }
+ ]
+};
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } });
+ const page = A.page;
+ await page.evaluate(async (doc) => {
+ const s = window.__stores;
+ await s.moduleSDK.initModules([{ id: 'sharpall', name: 'Sharpness fixture', version: '1.0.0', description: '33-games', register() {} }]);
+ s.gameKit.gameSettings.forceGameId('sharp-all');
+ s.hudDocs.setHudDocFor('scene', doc);
+ s.gameState.setGameState('menu');
+ s.commandsHandler.sceneCommand('/create box');
+ s.isVRMode.set(true);
+ s.objectActions.setEditorMode('interact');
+ }, GAME);
+ await page.waitForTimeout(800);
+
+ /** one frame from a standing head, the left wrist turned up to read, every surface measured */
+ const measure = (what) =>
+ page.evaluate(async (what) => {
+ const s = window.__stores;
+ const T = s.THREE;
+ const k = s.gameKit.vrGamePanel;
+ const head = { position: new T.Vector3(0, 1.6, 0), quaternion: new T.Quaternion() };
+ // the wrist where a player reads it: ~0.4 m below-ahead, the card facing the eyes
+ const left = { position: new T.Vector3(-0.08, 1.33, -0.28), quaternion: new T.Quaternion().setFromEuler(new T.Euler(0.9, 0, 0)) };
+ if (what === 'banner') s.gameKit.gameAnnounce.announce('Level 3', { sub: 'Light every star', ms: 4000 });
+ for (let i = 0; i < 4; i++) k.vrGamePanelFrame({ head, hands: [left, null], force: true });
+ const out = {};
+ for (const name of ['vr-game-panel', 'vr-game-wrist', 'vr-game-strip', 'vr-game-announce']) {
+ const surf = k.vrGameSurface(name);
+ if (!surf?.mesh?.visible) continue;
+ const worldW = surf.mesh.geometry.parameters.width;
+ const centre = surf.mesh.getWorldPosition(new T.Vector3());
+ const dist = centre.distanceTo(head.position);
+ out[name] = { px: surf.canvas.width, worldW, dist, ratio: k.texelRatio(surf.canvas.width, worldW, dist) };
+ }
+ return out;
+ }, what);
+
+ const fmt = (m) => `${m.px} px / ${m.worldW.toFixed(3)} m at ${m.dist.toFixed(2)} m = ${m.ratio.toFixed(2)}x`;
+
+ // ---- the game's own menu on the board, the wrist card
+ const menu = await measure('menu');
+ h.check(!!menu['vr-game-panel'], '(premise) the board shows the game menu');
+ h.check(!!menu['vr-game-wrist'], '(premise) the wrist card is drawn');
+ if (menu['vr-game-panel']) h.check(menu['vr-game-panel'].ratio >= 1, `the game board is crisp: ${fmt(menu['vr-game-panel'])}`);
+ if (menu['vr-game-wrist']) h.check(menu['vr-game-wrist'].dist < 0.6 && menu['vr-game-wrist'].ratio >= 1, `the wrist card is crisp where it is read: ${fmt(menu['vr-game-wrist'])}`);
+
+ // ---- the pause menu's pages on the board
+ await page.evaluate(() => window.__stores.gameKit.gameShell.openShellMenu('settings'));
+ const shell = await measure('shell');
+ h.check(!!shell['vr-game-panel'] && shell['vr-game-panel'].ratio >= 1, `the pause menu on the board is crisp: ${shell['vr-game-panel'] ? fmt(shell['vr-game-panel']) : 'not drawn'}`);
+ await page.evaluate(() => window.__stores.gameKit.gameShell.closeShellMenu());
+
+ // ---- in a round: the top strip, and the announce banner
+ await page.evaluate(() => window.__stores.gameState.setGameState('playing'));
+ await page.waitForTimeout(300);
+ const round = await measure('banner');
+ h.check(!!round['vr-game-strip'] && round['vr-game-strip'].ratio >= 1, `the top strip is crisp: ${round['vr-game-strip'] ? fmt(round['vr-game-strip']) : 'not drawn'}`);
+ h.check(!!round['vr-game-announce'] && round['vr-game-announce'].ratio >= 1, `the announce banner is crisp: ${round['vr-game-announce'] ? fmt(round['vr-game-announce']) : 'not drawn'}`);
+
+ // ---- every canvas-textured surface the VR game UI draws was measured above
+ const canvases = await page.evaluate(() => {
+ const s = window.__stores;
+ let scene;
+ s.globalScene.subscribe((v) => (scene = v))();
+ const names = [];
+ scene.traverse((o) => {
+ if (o.isMesh && o.visible && o.material?.map?.isCanvasTexture && /^vr-/.test(o.name)) names.push(o.name);
+ });
+ return names;
+ });
+ const measured = ['vr-game-panel', 'vr-game-wrist', 'vr-game-strip', 'vr-game-announce'];
+ const unmeasured = canvases.filter((n) => !measured.includes(n));
+ h.check(unmeasured.length === 0, `every visible VR canvas panel is in the probe (${canvases.join(', ')}${unmeasured.length ? ' — NOT measured: ' + unmeasured.join(', ') : ''})`);
+
+ // ---- the rendered check: the wrist card's text edges, from the eye, at its real distance
+ await page.evaluate(() => window.__stores.gameState.setGameState('menu'));
+ const render = await page.evaluate(async () => {
+ const s = window.__stores;
+ const T = s.THREE;
+ const k = s.gameKit.vrGamePanel;
+ let renderer, scene;
+ s.globalRenderer.subscribe((v) => (renderer = v))();
+ s.globalScene.subscribe((v) => (scene = v))();
+ const head = { position: new T.Vector3(0, 1.6, 0), quaternion: new T.Quaternion() };
+ const left = { position: new T.Vector3(-0.08, 1.33, -0.28), quaternion: new T.Quaternion().setFromEuler(new T.Euler(0.9, 0, 0)) };
+ k.vrGamePanelFrame({ head, hands: [left, null], force: true });
+ const wrist = k.vrGameSurface('vr-game-wrist');
+ // a camera at the eye looking at the card, a field of view of a headset eye (~100 deg
+ // across 2064 px = Quest 3) rendered at 2064 px wide: one render px = one headset px
+ const W = 1032;
+ const H = 1032;
+ const cam = new T.PerspectiveCamera(50, 1, 0.02, 50);
+ cam.position.copy(head.position);
+ cam.lookAt(wrist.mesh.getWorldPosition(new T.Vector3()));
+ cam.updateMatrixWorld(true);
+ const box = new T.Box3().setFromObject(wrist.mesh);
+ const pts = [];
+ for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) pts.push(new T.Vector3(x, y, z).project(cam));
+ const rx0 = Math.max(1, Math.floor(((Math.min(...pts.map((p) => p.x)) + 1) / 2) * W));
+ const rx1 = Math.min(W - 1, Math.ceil(((Math.max(...pts.map((p) => p.x)) + 1) / 2) * W));
+ const ry0 = Math.max(1, Math.floor(((1 - Math.max(...pts.map((p) => p.y))) / 2) * H));
+ const ry1 = Math.min(H - 1, Math.ceil(((1 - Math.min(...pts.map((p) => p.y))) / 2) * H));
+ const energy = () => {
+ const rt = new T.WebGLRenderTarget(W, H);
+ rt.texture.colorSpace = T.SRGBColorSpace;
+ const xrOn = renderer.xr.enabled;
+ renderer.xr.enabled = false;
+ renderer.setRenderTarget(rt);
+ renderer.render(scene, cam);
+ const px = new Uint8Array(W * H * 4);
+ renderer.readRenderTargetPixels(rt, 0, 0, W, H, px);
+ renderer.setRenderTarget(null);
+ renderer.xr.enabled = xrOn;
+ rt.dispose();
+ const lum = new Float32Array(W * H);
+ for (let i = 0; i < W * H; i++) lum[i] = 0.2126 * px[i * 4] + 0.7152 * px[i * 4 + 1] + 0.0722 * px[i * 4 + 2];
+ let sum = 0;
+ let n = 0;
+ for (let y = ry0; y < ry1; y++)
+ for (let x = rx0; x < rx1; x++) {
+ const i = y * W + x;
+ const lap = 4 * lum[i] - lum[i - 1] - lum[i + 1] - lum[i - W] - lum[i + W];
+ sum += lap * lap;
+ n++;
+ }
+ return { e: n ? sum / n : 0, n };
+ };
+ const full = energy();
+ // the same card with HALF its texels: drawn small, stretched back by the texture
+ const c = wrist.canvas;
+ const half = document.createElement('canvas');
+ half.width = c.width / 2;
+ half.height = c.height / 2;
+ half.getContext('2d').drawImage(c, 0, 0, half.width, half.height);
+ const g = c.getContext('2d');
+ const keep = g.getImageData(0, 0, c.width, c.height);
+ g.clearRect(0, 0, c.width, c.height);
+ g.imageSmoothingEnabled = true;
+ g.drawImage(half, 0, 0, c.width, c.height);
+ wrist.mesh.material.map.needsUpdate = true;
+ const soft = energy();
+ g.putImageData(keep, 0, 0);
+ wrist.mesh.material.map.needsUpdate = true;
+ return { full: full.e, soft: soft.e, n: full.n, rect: [rx0, ry0, rx1, ry1] };
+ });
+ h.check(render.n > 2000 && render.full > 20, `(premise) the wrist card covers ${render.n} eye pixels with text edges to measure (energy ${render.full.toFixed(1)})`);
+ h.check(render.full > render.soft * 1.15, `the wrist card's texels show at the eye: full ${render.full.toFixed(1)} vs half the texels ${render.soft.toFixed(1)} (x${(render.full / Math.max(1e-6, render.soft)).toFixed(2)})`);
+
+ await page.evaluate(() => window.__stores.isVRMode.set(false));
+ await h.finish(browser);
+});
diff --git a/tests/e2e/vr-two-hand-grab.test.cjs b/tests/e2e/vr-two-hand-grab.test.cjs
new file mode 100644
index 00000000..2c181e8b
--- /dev/null
+++ b/tests/e2e/vr-two-hand-grab.test.cjs
@@ -0,0 +1,192 @@
+// 33 G4: TWO HANDS, TWO THINGS. The Quest report (Towers): "when I grab another box for some
+// reason the other one stuck in space until I take it again (only then physics applied to
+// it)". vrControls kept ONE grab slot: the second hand's grab overwrote the first, the first
+// piece stayed in its kinematic physics hold, nothing ever updated or released it, and it hung
+// in mid-air until a hand took it again. Grabs are per HAND now.
+//
+// Driven through the REAL per-frame path (Scene's useTask -> updateVRControls) with a fake XR
+// session (fakeXR.cjs) and a running rapier simulation: two emulated controllers each hold a
+// different dynamic crate, carry both, and release them one at a time.
+const h = require('./helpers.cjs');
+const xr = require('./fakeXR.cjs');
+
+const grips = (page) => page.evaluate(() => window.__stores.vrControls.vrGripDebug());
+const posOf = (page, uuid) =>
+ page.evaluate((uuid) => {
+ let g;
+ window.__stores.objectsGroup.subscribe((x) => (g = x))();
+ return g.getObjectByProperty('uuid', uuid).position.toArray();
+ }, uuid);
+const bodyOf = (page, uuid) =>
+ page.evaluate((uuid) => {
+ const b = window.__stores.physics.physicsDebug().find((e) => e.uuid === uuid);
+ return b ? { hold: b.hold, bodyType: b.bodyType } : null;
+ }, uuid);
+const grip = async (page, hand, down) => {
+ await xr.button(page, hand, 1, down);
+ await page.waitForTimeout(250);
+};
+
+h.run(async () => {
+ // GPU: software GL runs a few fps and the fixed-step accumulator then lags real time
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+ await A.page.evaluate(() => window.__stores.physics.warmup().catch(() => {}));
+ await A.page.waitForTimeout(2000);
+
+ // ---- the fixture: a floor and two dynamic crates side by side ---------------------------
+ const ids = await A.page.evaluate(async () => {
+ const s = window.__stores;
+ const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; };
+ for (let i = 0; i < 3; i++) s.commandsHandler.sceneCommand('/create box');
+ await new Promise((r) => setTimeout(r, 1200));
+ const [floor, a, b] = get(s.objectsGroup).children.filter((c) => c.name === 'Box');
+ floor.scale.set(20, 0.5, 20);
+ floor.position.set(0, -0.25, 0);
+ floor.userData.physics = { mode: 'static' };
+ for (const [box, x] of [[a, 0.7], [b, -0.7]]) {
+ box.scale.setScalar(0.4);
+ box.position.set(x, 0.2, -1.5);
+ box.rotation.set(0, 0, 0);
+ box.userData.physics = { mode: 'dynamic', mass: 1 };
+ }
+ for (const o of [floor, a, b]) o.updateMatrixWorld(true);
+ s.objectActions.deselectObject();
+ s.isVRMode.set(true);
+ return { floor: floor.uuid, a: a.uuid, b: b.uuid };
+ });
+ await A.page.waitForTimeout(600);
+ await xr.install(A.page);
+ // park both hands high and aimed at nothing (straight up)
+ const park = async () => {
+ await xr.pose(A.page, 'left', [-0.3, 1.6, 0], { pitch: Math.PI / 2 });
+ await xr.pose(A.page, 'right', [0.3, 1.6, 0], { pitch: Math.PI / 2 });
+ };
+ await park();
+
+ // ================================================================ 1. EDIT, no simulation
+ console.log('\n=== 1. Edit: each hand holds its own crate ===');
+ {
+ const a0 = await posOf(A.page, ids.a);
+ const b0 = await posOf(A.page, ids.b);
+ await xr.pose(A.page, 'right', [a0[0], a0[1], 0]); // aims -Z through crate A
+ await grip(A.page, 'right', true);
+ await xr.pose(A.page, 'left', [b0[0], b0[1], 0]); // aims -Z through crate B
+ await grip(A.page, 'left', true);
+ const g = await grips(A.page);
+ h.check(g.grabs[1] === ids.a && g.grabs[0] === ids.b, `1.1 the right hand holds A, the left holds B (${JSON.stringify(g.grabs)})`);
+ // lift both hands half a metre: BOTH crates follow their own hand
+ await xr.pose(A.page, 'right', [a0[0] + 0.3, a0[1] + 0.5, 0]);
+ await xr.pose(A.page, 'left', [b0[0] - 0.3, b0[1] + 0.5, 0]);
+ await A.page.waitForTimeout(300);
+ const a1 = await posOf(A.page, ids.a);
+ const b1 = await posOf(A.page, ids.b);
+ h.check(Math.abs(a1[1] - (a0[1] + 0.5)) < 0.05 && Math.abs(a1[0] - (a0[0] + 0.3)) < 0.05, `1.2 crate A follows the right hand (y ${a0[1].toFixed(2)} -> ${a1[1].toFixed(2)})`);
+ h.check(Math.abs(b1[1] - (b0[1] + 0.5)) < 0.05 && Math.abs(b1[0] - (b0[0] - 0.3)) < 0.05, `1.3 crate B follows the left hand (y ${b0[1].toFixed(2)} -> ${b1[1].toFixed(2)})`);
+ await grip(A.page, 'right', false);
+ await grip(A.page, 'left', false);
+ const g2 = await grips(A.page);
+ h.check(g2.grabs.every((u) => u === null), `1.4 both releases end both grabs (${JSON.stringify(g2.grabs)})`);
+ // the editor's two-hand SCALE still happens when both hands take the SAME object
+ const a2 = await posOf(A.page, ids.a);
+ await xr.pose(A.page, 'right', [a2[0], a2[1], 0]);
+ await grip(A.page, 'right', true);
+ await xr.pose(A.page, 'left', [a2[0] - 0.05, a2[1], 0]);
+ await grip(A.page, 'left', true);
+ const g3 = await grips(A.page);
+ h.check(g3.scaleGrab === ids.a && g3.grabs.every((u) => u === null), `1.5 two hands on ONE object still scale it in Edit (${g3.scaleGrab === ids.a})`);
+ await grip(A.page, 'left', false);
+ await grip(A.page, 'right', false);
+ // put the crates back on the floor for the simulation
+ await A.page.evaluate((ids) => {
+ let g;
+ window.__stores.objectsGroup.subscribe((x) => (g = x))();
+ for (const [u, x] of [[ids.a, 0.7], [ids.b, -0.7]]) {
+ const o = g.getObjectByProperty('uuid', u);
+ o.position.set(x, 0.2, -1.5);
+ o.rotation.set(0, 0, 0);
+ o.scale.setScalar(0.4);
+ o.updateMatrixWorld(true);
+ }
+ window.__stores.objectActions.deselectObject();
+ }, ids);
+ await park();
+ }
+
+ // ================================================================ 2. INTERACT + physics
+ console.log('\n=== 2. Interact + a running simulation: two crates, two hands ===');
+ await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact'));
+ await A.page.evaluate(() => window.__stores.physics.toggleSimulation());
+ await A.page.waitForTimeout(1500);
+ const running = await A.page.evaluate(() => window.__stores.physics.physicsDebug().length);
+ h.check(running >= 2, `2.0 (premise) the simulation is running (${running} dynamic bodies)`);
+ const a0 = await posOf(A.page, ids.a);
+ const b0 = await posOf(A.page, ids.b);
+ h.check(a0[1] < 0.4 && b0[1] < 0.4, `2.0b (premise) both crates rest on the floor (y ${a0[1].toFixed(2)}, ${b0[1].toFixed(2)})`);
+
+ await xr.pose(A.page, 'right', [a0[0], a0[1], 0]);
+ await grip(A.page, 'right', true);
+ let ba = await bodyOf(A.page, ids.a);
+ h.check(ba?.hold === 'user', `2.1 the right hand holds crate A (hold ${ba?.hold})`);
+ await xr.pose(A.page, 'left', [b0[0], b0[1], 0]);
+ await grip(A.page, 'left', true);
+ let g = await grips(A.page);
+ h.check(g.grabs[1] === ids.a && g.grabs[0] === ids.b, `2.2 the second hand takes B and the first keeps A (${JSON.stringify(g.grabs)})`);
+
+ // carry both up to 1.3 m: each crate rides ITS hand (the bug: A hung where B's grab began)
+ await xr.pose(A.page, 'right', [a0[0], 1.3, 0]);
+ await xr.pose(A.page, 'left', [b0[0], 1.3, 0]);
+ await A.page.waitForTimeout(400);
+ const a1 = await posOf(A.page, ids.a);
+ const b1 = await posOf(A.page, ids.b);
+ h.check(Math.abs(a1[1] - 1.3) < 0.08, `2.3 crate A rises with the right hand while the left holds B (y ${a1[1].toFixed(2)})`);
+ h.check(Math.abs(b1[1] - 1.3) < 0.08, `2.4 crate B rises with the left hand (y ${b1[1].toFixed(2)})`);
+
+ // release A only: it is a dynamic body AT ONCE and falls; B stays in the left hand
+ await xr.button(A.page, 'right', 1, false);
+ await A.page.waitForTimeout(120);
+ ba = await bodyOf(A.page, ids.a);
+ let bb = await bodyOf(A.page, ids.b);
+ h.check(ba?.hold === null && ba?.bodyType === 0, `2.5 released crate A gets physics immediately (hold ${ba?.hold}, bodyType ${ba?.bodyType})`);
+ h.check(bb?.hold === 'user', `2.6 crate B is still held by the left hand (hold ${bb?.hold})`);
+ await A.page.waitForTimeout(900);
+ const a2 = await posOf(A.page, ids.a);
+ const b2 = await posOf(A.page, ids.b);
+ h.check(a2[1] < 0.6, `2.7 crate A fell (y ${a1[1].toFixed(2)} -> ${a2[1].toFixed(2)})`);
+ h.check(Math.abs(b2[1] - 1.3) < 0.08, `2.8 crate B did not fall while held (y ${b2[1].toFixed(2)})`);
+ // the left hand still carries B
+ await xr.pose(A.page, 'left', [b0[0] - 0.4, 1.3, 0]);
+ await A.page.waitForTimeout(300);
+ const b3 = await posOf(A.page, ids.b);
+ h.check(Math.abs(b3[0] - (b0[0] - 0.4)) < 0.08, `2.9 crate B still follows the left hand (x ${b3[0].toFixed(2)})`);
+
+ // release B: it falls too
+ await xr.button(A.page, 'left', 1, false);
+ await A.page.waitForTimeout(1000);
+ bb = await bodyOf(A.page, ids.b);
+ const b4 = await posOf(A.page, ids.b);
+ h.check(bb?.hold === null && b4[1] < 0.6, `2.10 released crate B falls (hold ${bb?.hold}, y ${b4[1].toFixed(2)})`);
+ g = await grips(A.page);
+ h.check(g.grabs.every((u) => u === null), `2.11 no hand holds anything (${JSON.stringify(g.grabs)})`);
+
+ // a re-grab in the SAME hand of another crate releases the first (a hand holds one thing)
+ const a5 = await posOf(A.page, ids.a);
+ const b5 = await posOf(A.page, ids.b);
+ await xr.pose(A.page, 'right', [a5[0], a5[1], 0]);
+ await grip(A.page, 'right', true);
+ await xr.pose(A.page, 'right', [a5[0], 1.2, 0]);
+ await A.page.waitForTimeout(300);
+ // the knock probe skips EVERYTHING the hands hold (both of them)
+ await xr.pose(A.page, 'left', [b5[0], b5[1], 0]);
+ await grip(A.page, 'left', true);
+ const held = await A.page.evaluate(() => window.__stores.vrControls.vrGrabbedUuids());
+ h.check(held.includes(ids.a) && held.includes(ids.b), `2.12 vrGrabbedUuids names both held crates (${held.length})`);
+ await grip(A.page, 'left', false);
+ await grip(A.page, 'right', false);
+
+ await A.page.evaluate(() => window.__stores.physics.stopSimulation());
+ await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit'));
+ await xr.uninstall(A.page);
+ await A.page.evaluate(() => window.__stores.isVRMode.set(false));
+ await h.finish(browser);
+});
diff --git a/tests/unit/qualityGovernor.test.js b/tests/unit/qualityGovernor.test.js
index 7a5599e5..f89a14ed 100644
--- a/tests/unit/qualityGovernor.test.js
+++ b/tests/unit/qualityGovernor.test.js
@@ -248,3 +248,40 @@ describe('31-perf: XR thresholds', () => {
expect(XR_FRAMEBUFFER_SCALE).toBe(1);
});
});
+
+// 33 G1 — a phone starts lighter and is always governed.
+import { PHONE_START_LEVEL, isPhoneLike, overridesAt as phoneOverridesAt } from '../../src/lib/qualityGovernorCore.js';
+
+describe('33 G1: phones', () => {
+ it('a phone is a coarse pointer, no hover and a short screen side', () => {
+ expect(isPhoneLike({ coarse: true, hover: false, minSide: 412 })).toBe(true);
+ expect(isPhoneLike({ coarse: true, hover: false, minSide: 820 })).toBe(true); // a small tablet
+ expect(isPhoneLike({ coarse: true, hover: true, minSide: 412 })).toBe(false); // a touch laptop has hover
+ expect(isPhoneLike({ coarse: false, hover: false, minSide: 412 })).toBe(false);
+ expect(isPhoneLike({ coarse: true, hover: false, minSide: 1080 })).toBe(false);
+ expect(isPhoneLike({ coarse: true, hover: false, minSide: 0 })).toBe(false);
+ expect(isPhoneLike({})).toBe(false);
+ });
+ it('the phone start is shadows off, 72 % resolution and AO off — the post look stays', () => {
+ expect(GOVERNOR_STEPS.slice(0, PHONE_START_LEVEL).map((s) => s.key)).toEqual(['shadows', 'res85', 'res72', 'ao']);
+ const o = phoneOverridesAt(PHONE_START_LEVEL);
+ expect(o).toMatchObject({ shadowsOff: true, dprScale: 0.72, aoOff: true, postOff: false });
+ });
+ it('a LIGHT scene on slow frames: governed when judged heavy (the phone rule), never when not', () => {
+ const run = (/** @type {boolean} */ heavy) => {
+ const g = createGovernor();
+ let t = 1000;
+ g.setLevel(PHONE_START_LEVEL, t);
+ t += 700;
+ for (let i = 0; i < 100; i++) {
+ t += 50;
+ g.noteFrame(50, t);
+ }
+ return g.decide(t, { heavy });
+ };
+ expect(run(true).moved).toBe('up');
+ // the desktop rule: a light scene is never governed — and is walked back to full quality
+ const light = run(false);
+ expect(light.moved).not.toBe('up');
+ });
+});