VR pet: a cat or dog in SteamVR's scene (steamFrame.pet)

A SteamVR dashboard patch (vr-pet) drawing a baked 3D pet next to the
windows: the Toon Cat in five coats, a Shiba Inu, a Fox and a Dachshund,
plus models from steamFrame.pet.extraModels. It walks around, follows,
sits, lies and sleeps, can be picked up by its grip bar, petted and
switched in its menu; its spot, pose and model are saved state.

Also the vr-pet command, "Pet" in the "+" menu with the current model's
icon (steam-frame-nix-pet-icon), the flake outputs pet-models,
pet-icons and pet-preview, and the check pet. A built-in model with
"private": true fails evaluation; private models belong in extraModels.
This commit is contained in:
Pierre Kisters committed 2026-10-01 04:33:39 +02:00
1 parent fbf9519be0
commit 4034fd4ded
38 files changed
+7216 -5

No files matched your search

+985
View File
@@ -0,0 +1,985 @@
// Headless tests of the SteamVR adapter (systemui.js) in a fake systemui page:
// a minimal DOM, VRHTML (poses, sgids), SGApp's embedded-UV table, the scene
// graph module (slow send) and mailbox (captured socket: fast send), the
// steam-ui-patches store, and fake timers (virtual clock).
// usage: node systemui.test.js <models.json> (exit 1 on failure)
// Run by the flake check `pet` (package.nix `tests`).
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const catalog = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); // index.py's models.json
const baked0 = catalog.frameSets[catalog.models[catalog.default].frames];
const src = ['core.js', 'systemui.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');
const PREFIX = 'mailbox_send vrcompositor_systemlayer ';
// ---- fake DOM ----
class El {
constructor(tag, doc) { this.tagName = tag.toUpperCase(); this.doc = doc; this.attrs = {}; this.children = []; this.parent = null; this.style = {}; this.listeners = {}; this.id = ''; this.textContent = ''; }
setAttribute(k, v) { this.attrs[k] = String(v); }
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
removeAttribute(k) { delete this.attrs[k]; }
hasAttribute(k) { return k in this.attrs; }
appendChild(c) { c.remove(); c.parent = this; this.children.push(c); return c; }
remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } }
replaceWith(n) { const p = this.parent, i = p.children.indexOf(this); n.remove(); p.children[i] = n; n.parent = p; this.parent = null; }
get firstChild() { return this.children[0] ?? null; }
get parentNode() { return this.parent; }
get isConnected() { let e = this; while (e.parent) e = e.parent; return e === this.doc.body; }
addEventListener(t, f) { (this.listeners[t] ??= []).push(f); }
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] ?? []).filter((g) => g !== f); }
getBoundingClientRect() {
if (!this.isConnected || this.style.position !== 'fixed') return { x: 0, y: 0, left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 };
const x = parseFloat(this.style.left), y = parseFloat(this.style.top), w = parseFloat(this.style.width), h = parseFloat(this.style.height);
return { x, y, left: x, top: y, width: w, height: h, right: x + w, bottom: y + h };
}
*walk() { yield this; for (const c of this.children) yield* c.walk(); }
// A laser event, as the browser runs it: window's capture listeners first
// (whatever the target does), then the target and its ancestors, then
// window's bubbling listeners, unless stopped.
dispatch(type, mods = {}) {
const e = { type, target: this, stopped: false, stopPropagation() { this.stopped = true; }, getModifierState: (k) => !!mods[k] };
const win = this.doc.win.listeners[type] ?? [];
for (const [f, capture] of win) if (capture) f(e);
for (let n = this; n && !e.stopped; n = n.parent) for (const f of n.listeners[type] ?? []) f(e);
if (!e.stopped) for (const [f, capture] of win) if (!capture) f(e);
return e;
}
}
function page({ store = {}, sgQuery = true, setup = null } = {}) {
// virtual clock and timers
const clock = { now: 0, id: 0, timers: new Map(), scheduled: 0 };
const setT = (f, ms, every) => { const id = ++clock.id; clock.scheduled++; clock.timers.set(id, { f, at: clock.now + Math.max(0, ms || 0), every: every ? Math.max(1, ms) : 0 }); return id; };
const advance = async (ms) => {
const end = clock.now + ms;
for (;;) {
let next = null;
for (const [id, t] of clock.timers) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t];
if (!next) break;
const [id, t] = next;
clock.now = t.at;
if (t.every) t.at += t.every; else clock.timers.delete(id);
t.f();
await new Promise((r) => setImmediate(r)); // promise callbacks (where()) between timers
}
clock.now = end;
await new Promise((r) => setImmediate(r));
};
const doc = { };
const win = { listeners: {}, addEventListener(t, f, capture = false) { (this.listeners[t] ??= []).push([f, !!(capture?.capture ?? capture)]); },
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] ?? []).filter(([g]) => g !== f); } };
doc.win = win;
doc.all = []; // every element made (also those out of the page: a hidden cat's)
doc.createElement = (t) => { const e = new El(t, doc); doc.all.push(e); return e; };
doc.body = new El('body', doc);
doc.head = new El('head', doc);
doc.querySelectorAll = (sel) => {
const m = /^\[([\w-]+)="([^"]+)"\]$/.exec(sel);
return [...doc.body.walk()].filter((e) => m && e.getAttribute(m[1]) === m[2]);
};
// A dashboard panel at the top of the page (the free spot must avoid it).
const dash = doc.createElement('vsg-node');
dash.setAttribute('vsg-type', 'panel');
const dashContent = doc.createElement('div');
Object.assign(dashContent.style, { position: 'fixed', left: '0px', top: '0px', width: '1280px', height: '600px' });
dash.getBoundingClientRect = () => dashContent.getBoundingClientRect();
dash.appendChild(dashContent);
doc.body.appendChild(dash);
const stats = { slow: 0, fast: 0, force: 0, get total() { return this.slow + this.fast + this.force; }, lastFast: null };
let sgid = 1000;
const poses = { '/user/head': { p: [0, 1.6, 1.6], q: [1, 0, 0, 0] }, '/user/hand/left': null, '/user/hand/right': null };
const nodePoses = {}; // sg query answers by id (default: its pose in the DOM, as vrcompositor has it at rest)
const embedded = [];
// The scene graph mailbox and its socket; the dashboard's send (slow path)
// walks the DOM: our root's node is in the message only while connected.
const sock = { readyState: 1, send(str) { if (str.startsWith(PREFIX)) stats.fast++; stats.lastSent = str; } };
class Mailbox { SendMessage() {} SendMessageAndWaitForResponse() {} RegisterHandler() {} }
const mailbox = new Mailbox();
mailbox.m_wsWebSocketToServer = sock;
const slowSend = function update_scene_graph() {
stats.slow++; stats.fast--; // the socket counts it too
const root = [...doc.body.children].find((e) => e.id === 'sfui-vr-pet');
mailbox.SendMessage('vrcompositor_systemlayer', { type: 'update_scene_graph' });
const msg = { type: 'update_scene_graph', scene_graph: { properties: { eDashboardRelatch: 0 }, children: root ? [{ type: 'transform', properties: { id: `system.systemui::${root.id}`, sgid: +root.getAttribute('sgid') }, children: [] }] : [] } };
mailbox.m_wsWebSocketToServer.send(PREFIX + JSON.stringify(msg));
};
const req = {};
const find = {
getWebpackRequire: () => req,
findModule(r, spec, label) {
if (label === 'scene graph module') return { exports: { send: slowSend } };
if (label === 'mailbox module') return { exports: { Mailbox } };
throw new Error(`no ${label}`);
},
findExport(exports, spec) {
const v = Object.values(exports).find((x) => typeof x === 'function');
return { value: v };
},
};
const hooks = {
before(obj, m, name, f) { const orig = obj[m]; obj[m] = function (...a) { f.call(this, a); return orig.apply(this, a); }; obj[m].__orig = orig; },
remove(obj, m) { if (obj[m].__orig) obj[m] = obj[m].__orig; },
};
const xf = (p) => ({ translation: { x: p.p[0], y: p.p[1], z: p.p[2] }, rotation: { w: p.q[0], x: p.q[1], y: p.q[2], z: p.q[3] } });
const G = {
window: null, document: doc, innerWidth: 1280, innerHeight: 1440,
performance: { now: () => clock.now },
setTimeout: (f, ms) => setT(f, ms, false), clearTimeout: (id) => clock.timers.delete(id),
setInterval: (f, ms) => setT(f, ms, true), clearInterval: (id) => clock.timers.delete(id),
queueMicrotask, Promise, JSON, Math, Object, Array, Set, Map, Number, String, Error, console,
addEventListener: (t, f, c) => win.addEventListener(t, f, c), removeEventListener: (t, f, c) => win.removeEventListener(t, f, c),
forceLayoutUpdate: () => { stats.force++; G.SGApp.updateAllPanelBounds(); },
VRHTML: {
NextSGID: () => ++sgid,
GetPose: (p) => [poses[p] ? { bPoseIsValid: true, xfDeviceToAbsoluteTracking: xf(poses[p]), vVelocity: { x: 0, y: 0, z: 0 } } : { bPoseIsValid: false }],
VROverlay: { ThisOverlayKey: () => 'system.systemui' },
},
// SGApp: its panels by sgid (the embedded ones too); a layout measures
// every panel (updateLayoutValues: its m_Rect, the slot's rect).
SGApp: {
m_mapPanels: new Map(),
addEmbeddedPanelUVs(fp) { embedded.push(fp); this.m_mapPanels.set(fp.getSGID(), fp); return embedded.length - 1; },
removeEmbeddedPanelUVs(fp) { const i = embedded.indexOf(fp); if (i >= 0) embedded[i] = null; this.m_mapPanels.delete(fp.getSGID()); },
updateAllPanelBounds() { this.m_mapPanels.forEach((fp) => fp.updateLayoutValues?.()); },
},
__sfuiStore: { data: { 'vr-pet': store }, get(n) { return this.data[n]; }, set(n, v) { this.data[n] = v; return true; } },
};
// A node's world pose from the DOM's translation / rotation attributes.
const domPose = (id) => {
const local = id.split('::').pop();
let e = [...doc.body.walk()].find((n) => n.id === local), w = { p: [0, 0, 0], q: [1, 0, 0, 0] };
for (; e && e !== doc.body; e = e.parent) {
const t = e.getAttribute('translation'), r = e.getAttribute('rotation');
if (t || r) w = qc({ p: t ? t.split(' ').map(Number) : [0, 0, 0], q: r ? r.split(' ').map(Number) : [1, 0, 0, 0] }, w);
}
return w;
};
// vrcompositor, as far as the cat relies on it: the world poses of the last
// sent scene graph. A transform with a parent-path: that device's pose · its
// own, rigidly (its ancestors ignored), as SteamVR's floating windows use it;
// a grab-scale: its children pushed along its -Z by `vrc.push` (the
// thumbstick) while active, back to 0 on a new reset-generation.
const vrc = { push: 0, gens: new Map() };
vrc.world = () => {
const out = new Map();
const msg = stats.lastSent ? JSON.parse(stats.lastSent.slice(PREFIX.length)) : null;
const visit = (n, parent) => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) { n.forEach((c) => visit(c, parent)); return; }
const P = n.properties ?? {}, local = { p: P.translation ?? [0, 0, 0], q: P.rotation ?? [1, 0, 0, 0] };
const dev = P['parent-path'] ? poses[P['parent-path']] : null;
let w = parent;
if (n.type === 'transform') w = qc(dev ?? parent, local);
else if (n.type === 'grab-scale') {
if (vrc.gens.has(P.sgid) && vrc.gens.get(P.sgid) !== P['reset-generation']) vrc.push = 0;
vrc.gens.set(P.sgid, P['reset-generation']);
w = P['is-active'] ? qc(parent, { p: [0, 0, -vrc.push], q: [1, 0, 0, 0] }) : parent;
}
if (P.sgid != null) out.set(P.sgid, w);
if (P.id) out.set(P.id, w);
visit(n.children, w);
};
visit(msg?.scene_graph, { p: [0, 0, 0], q: [1, 0, 0, 0] });
return out;
};
const sgPoseOf = (id) => nodePoses[id] ?? vrc.world().get(id) ?? domPose(id);
if (sgQuery) {
G.SGQueryService = {
requestSGTransform: (id) => Promise.resolve(xf(sgPoseOf(id))),
requestSGTransformRelative: (from, id) => Promise.resolve({ ...xf(qc(qinv(sgPoseOf(from)), sgPoseOf(id))), scale: { x: 1, y: 1, z: 1 } }),
};
}
G.window = G;
const ctx = vm.createContext(G);
vm.runInContext(`${src}\n;globalThis.vrPetSystemui = vrPetSystemui;`, ctx);
const opts = { fps: 24, mount: 'dynamic', cat: {} };
const inject = () => ctx.vrPetSystemui(opts, catalog, find, hooks);
setup?.(G, doc);
return {
G, doc, clock, advance, stats, poses, nodePoses, domPose, vrc, embedded, mailbox, sock, inject,
// the patch's unpatch expression (unpatch.js), as the injector runs it when the service stops
unpatch: () => vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'unpatch.js'), 'utf8'), ctx),
listeners: () => Object.values(win.listeners).reduce((n, l) => n + l.length, 0),
get S() { return G.__sfuiPet; },
get stored() { return G.__sfuiStore.data['vr-pet']; },
activeTimers: () => clock.timers.size,
// A layout (the page's own, not a send), and whether a panel's embedded-UV slot holds a rect.
layout: () => G.SGApp.updateAllPanelBounds(),
slotValid: (fp) => !!fp && fp.idx != null && !fp.isExternal && fp.m_Rect.width > 0 && fp.m_Rect.height > 0,
fpOf: (id) => G.SGApp.m_mapPanels.get(+(doc.all.find((e) => e.id === id)?.getAttribute('sgid'))),
// A laser click on an element (mousedown, mouseup, click; hand in the modifier state).
click(el, hand = 'right') {
const mods = hand === 'left' ? { NumLock: true } : { CapsLock: true, NumLock: true };
el.dispatch('mousedown', mods); el.dispatch('mouseup', mods); return el.dispatch('click', mods);
},
byId: (id) => [...doc.body.walk()].find((e) => e.id === id) ?? doc.all.find((e) => e.id === id) ?? null,
byAttr: (k, v) => doc.all.find((e) => e.getAttribute(k) === v) ?? null,
dash: dashContent,
lastMsg: () => (stats.lastSent ? JSON.parse(stats.lastSent.slice(PREFIX.length)) : null),
};
}
// Pose maths (w x y z quaternions): compose, invert, rotate.
const qm = (a, b) => [a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3], a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1], a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0]];
const qconj = (q) => [q[0], -q[1], -q[2], -q[3]];
const qr = (q, v) => qm(qm(q, [0, ...v]), qconj(q)).slice(1);
const qaxis = (ax, deg) => { const h = deg * Math.PI / 360; return [Math.cos(h), ...ax.map((v) => v * Math.sin(h))]; };
function qc(a, b) { const r = qr(a.q, b.p); return { p: a.p.map((v, k) => v + r[k]), q: qm(a.q, b.q) }; }
const qinv = (a) => { const c = qconj(a.q); return { p: qr(c, a.p).map((v) => -v), q: c }; };
const sgPose = (n) => ({ p: n.properties.translation ?? [0, 0, 0], q: n.properties.rotation ?? [1, 0, 0, 0] });
let failed = 0;
const tests = [];
const test = (name, f) => tests.push([name, f]);
function assert(c, msg) { if (!c) throw new Error(msg); }
const near = (a, b, tol) => Math.abs(a - b) <= tol;
// Find a node by sgid in a scene graph message.
function findSg(n, pred) {
if (!n || typeof n !== 'object') return null;
if (Array.isArray(n)) { for (const c of n) { const r = findSg(c, pred); if (r) return r; } return null; }
if (pred(n)) return n;
return findSg(n.children, pred) ?? findSg(n.scene_graph, pred);
}
// A page with the cat running for `s` seconds (frames mounted, fast path captured).
async function running(s = 3, o) {
const p = page(o);
const r = p.inject();
assert(/^patched/.test(r), `injected (${r})`);
await p.advance(s * 1000);
return p;
}
// ---------------------------------------------------------------------------
test('injects: root in the page, timer, fast sends, api', async () => {
const p = await running();
assert(p.S.root.isConnected, 'root in the page');
assert(p.S.timer != null, 'timer running');
assert(p.stats.fast > 0, `fast sends (${JSON.stringify(p.S.stats().deferred)}, fast ${p.stats.fast})`);
assert(p.S.api?.version === 2 && ['show', 'hide', 'status', 'models', 'setModel'].every((k) => typeof p.S.api[k] === 'function'), 'api { version, show, hide, status, models, setModel }');
assert(p.inject() === 'unchanged', 're-inject of a shown cat: unchanged');
});
test('controls row (⋯ X): centred below the bar, laser-only, not a grab handle, in the fast send', async () => {
const p = await running();
const msg = p.lastMsg();
const sg = (id) => findSg(msg.scene_graph, (n) => n.properties?.id === `system.systemui::${id}`);
const bar = sg('sfui-vr-pet-handle'), x = sg('sfui-vr-pet-controls');
assert(bar && x, 'bar and controls panels in the fast send');
assert(!sg('sfui-vr-pet-menu'), 'no menu while closed');
assert(p.byAttr('data-act', 'menu') && p.byAttr('data-act', 'close'), 'buttons ⋯ and X');
assert(!findSg(msg.scene_graph, (n) => n.type === 'grab-transform'), 'no grab-transform (the dashboard\'s drag: not rigid on the laser)');
const push = findSg(msg.scene_graph, (n) => n.type === 'grab-scale');
assert(push && push.properties['is-active'] === false && !('event-panel-sgid' in push.properties), 'the push: inactive, no event panel at rest');
assert(findSg(push, (n) => n === x) && findSg(push, (n) => n === bar), 'the bar and the X hang under the push (carried with the drag)');
const lift = findSg(push, (n) => n.type === 'transform' && findSg(n.children, (c) => c === bar) && n.properties.translation[1] > 0.3);
assert(near(lift.properties.translation[1], 0.40, 1e-9), `lift 0.40 (${lift.properties.translation})`);
const facing = lift.children[0];
assert(facing.type === 'head-facing-transform' && facing.children[0] === bar, 'bar under the head-facing transform');
const below = facing.children[1];
assert(below.type === 'transform' && below.children[0] === x, 'X under the same head-facing transform');
const XP = x.properties, BP = bar.properties;
assert(near(below.properties.translation[1], -(0.008 + 0.04), 1e-9) && below.properties.translation[0] === 0, `controls 8 mm below the bar, centred (${below.properties.translation})`);
assert(XP.width === 0.08 && XP.origin[0] === 0 && XP.origin[1] === -1, 'controls 0.08 m, bottom-centre origin');
assert(XP['only-visible-with-laser'] === true && XP['is-grab-handle'] === false && XP['hide-laser-when-clicking'] === false && XP.interactive === true,
'X: laser-only, no grab handle, laser kept, interactive');
assert(BP['is-grab-handle'] === true, 'bar: grab handle');
// Textures: one free spot, the X's rect centred below the bar's, not overlapping, below the dashboard's panel.
const bu = [BP.uv_min, BP.uv_max].map(([u, v]) => [u * 1280, v * 1440]), xu = [XP.uv_min, XP.uv_max].map(([u, v]) => [u * 1280, v * 1440]);
assert(xu[0][1] >= bu[1][1], 'X texture below the bar texture');
assert(near((xu[0][0] + xu[1][0]) / 2, (bu[0][0] + bu[1][0]) / 2, 1e-6), 'X texture centred under the bar texture');
assert(near(xu[1][0] - xu[0][0], 96, 1e-6) && near(xu[1][1] - xu[0][1], 48, 1e-6), 'controls texture 96 x 48 px');
assert(bu[0][1] >= 600, 'free spot below the dashboard panel');
assert(p.embedded.length === 3 && XP['embedded-uv-index'] !== BP['embedded-uv-index'], 'three embedded UV slots (bar, controls, menu)');
// Dangling: the handle drops to liftDangle.
p.S.cat.command('dangle');
await p.advance(500);
const d = findSg(p.lastMsg().scene_graph, (n) => n.type === 'transform' && n.properties.sgid === lift.properties.sgid);
assert(near(d.properties.translation[1], 0.15, 1e-9), `dangle lift 0.15 (${d.properties.translation})`);
});
test('X click hides: one send, then no sends and no timers for 10 s; persisted', async () => {
const p = await running();
const before = p.stats.total, sched = p.clock.scheduled;
const e = p.click(p.byAttr('data-act', 'close'));
assert(e.stopped, 'the click does not reach the dashboard');
assert(p.S.hidden && !p.S.root.isConnected, 'hidden, root out of the page');
assert(p.stats.total - before === 1, `one send (${p.stats.total - before})`);
const m = p.lastMsg();
assert(!findSg(m.scene_graph, (n) => n.properties?.sgid === +p.S.root.getAttribute('sgid')), 'that send has no cat');
assert(p.S.stats().frames === 0 && ![...p.S.root.walk()].some((n) => n.buildNode?.({})?.[1]?.type === 'rendermodel'), 'frames unmounted');
assert(p.activeTimers() === 0, `no timers (${p.activeTimers()})`);
await p.advance(10000);
assert(p.stats.total - before === 1, `still one send after 10 s (${p.stats.total - before})`);
assert(p.clock.scheduled === sched, `no timer scheduled while hidden (${p.clock.scheduled - sched})`);
assert(p.stored.hidden === true && Number.isFinite(p.stored.x), `stored hidden with the spot (${JSON.stringify(p.stored)})`);
assert(p.inject() === 'unchanged', 're-inject while hidden: unchanged (not resurrected)');
assert(!p.S.root.isConnected, 'still out of the page');
assert(p.S.api.hide().was === 'hidden', 'hide again: already hidden');
assert(p.stats.total - before === 1, 'hide again: no send');
});
test('show: back at its spot (near, in view), gradual remount, fresh mover, persisted', async () => {
const p = await running();
p.S.cat.command('sit'); // stays put (a wandering cat's stored spot lags behind)
await p.advance(4000);
const at = p.S.cat.state();
const gen = p.S.stats().pushSent['reset-generation'];
p.S.api.hide();
const r = p.S.api.show();
assert(r.was === 'hidden' && !r.summoned && !p.S.hidden, `shown (${JSON.stringify(r)})`);
const st = p.S.cat.state();
assert(near(st.x, at.x, 1e-9) && near(st.z, at.z, 1e-9), 'at its spot');
assert(p.S.root.isConnected && p.S.timer != null, 'root and timer back');
assert(p.S.stats().pushSent['reset-generation'] > gen && !p.S.stats().carry, 'a fresh push (reset), not carried');
assert(p.S.stats().frames <= 3, `frames mount gradually (${p.S.stats().frames} on the first tick)`);
await p.advance(3000);
assert(p.S.stats().frames > 3, 'frames mounted again');
assert(p.stored.hidden === false && near(p.stored.x, p.S.cat.state().x, 0.5), `stored hidden: false, spot kept (${JSON.stringify(p.stored)})`);
const again = p.S.api.show();
assert(again.was === 'shown' && !again.summoned, 'show again: idempotent');
assert(p.inject() === 'unchanged', 'shown again: unchanged');
});
test('show: summoned when far or out of view, or forced', async () => {
const p = await running();
const head = p.poses['/user/head'];
// far: the cat 5 m away
p.S.api.hide();
p.poses['/user/head'] = { p: [0, 1.6, 6.6], q: [1, 0, 0, 0] };
let r = p.S.api.show();
let st = p.S.cat.state();
assert(r.summoned && near(st.x, 0, 1e-6) && near(st.z, 5.6, 1e-6), `far: summoned 1 m in front (${st.x}, ${st.z})`);
// behind the user: near but out of view
await p.advance(500);
p.S.api.hide();
p.poses['/user/head'] = { p: [0, 1.6, st.z - 1.2], q: [1, 0, 0, 0] }; // stepped past it, looking away
r = p.S.api.show();
assert(r.summoned, 'out of view: summoned');
// forced
await p.advance(500);
p.S.api.hide();
r = p.S.api.show({ summon: true });
assert(r.summoned, '--summon: summoned');
p.poses['/user/head'] = head;
});
test('X during a laser drag: inert (also released over it, and 300 ms after)', async () => {
const p = await running();
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
const R = { CapsLock: true, NumLock: true };
const bar = p.byId('sfui-vr-pet-handle-content'), x = p.byAttr('data-act', 'close');
const at = p.S.cat.state();
assert(!bar.hasAttribute('data-active') && bar.children[0]?.getAttribute('class') === 'sfui-vr-pet-grip', 'the bar: idle look, the grip');
bar.dispatch('mousedown', R);
await p.advance(300);
assert(p.S.stats().drag?.phase === 'drag' && p.S.stats().carry === '/user/hand/right', `dragging (${JSON.stringify(p.S.stats().drag)})`);
assert(p.S.cat.state().held?.source === 'laser', 'held by the laser');
assert(bar.hasAttribute('data-active'), 'the bar: dragging look during the drag');
// a press and release on the X while dragging (the other hand's laser): nothing
p.click(x, 'left');
assert(!p.S.hidden && p.S.stats().drag, 'X during the drag: still shown, still dragged');
assert(!x.hasAttribute('data-armed'), 'X not armed during the drag');
// the drag released over the X: ends the drag, does not hide
x.dispatch('mouseup', R); x.dispatch('click', R);
assert(!p.S.hidden, 'drag released over the X: shown');
// right after the drag: still inert
await p.advance(100);
p.click(x);
assert(!p.S.hidden, 'X 100 ms after the drag: shown');
await p.advance(1500);
assert(!p.S.stats().drag, 'drag over');
assert(!bar.hasAttribute('data-active'), 'the bar: dragging look gone after the release');
void at;
});
test('api.hide during a laser drag: drag cancelled, stands where it was carried (the hand still: the start); show: a fresh push', async () => {
const p = await running();
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
const at = p.S.cat.state();
const gen = p.S.stats().pushSent['reset-generation'];
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', { CapsLock: true, NumLock: true });
await p.advance(300);
assert(p.S.stats().drag?.phase === 'drag' && p.S.stats().carry, 'dragging, carried');
p.S.api.hide();
const st = p.S.cat.state();
assert(p.S.hidden && !p.S.stats().drag && p.S.root.getAttribute('parent-path') === null && !p.S.stats().pushSent['is-active'],
'drag cancelled (root back in the world, push inactive)');
assert(!st.held && !st.falling && st.pose === 'stand', `settled (${st.pose}, held ${JSON.stringify(st.held)})`);
// (settle(): a dangling cat stands below its scruff, up to the scruff's
// offset from the feet away from where it was picked up)
const off = Math.hypot(...baked0.poseZones.stand.scruff.filter((_, k) => k !== 1)) + 0.02;
assert(Math.hypot(st.x - at.x, st.z - at.z) < off, `at the drag's start (${st.x.toFixed(3)} ${st.z.toFixed(3)} vs ${at.x.toFixed(3)} ${at.z.toFixed(3)})`);
await p.advance(2000);
p.S.api.show();
assert(p.S.stats().pushSent['reset-generation'] > gen && !p.S.stats().carry, 'fresh push, not carried');
await p.advance(500);
assert(!p.S.cat.state().held, 'not held after show');
});
// A laser drag of the bar by the right hand, pressed at H0, checked against
// the vrcompositor model (p.vrc): dr.move(H) sets the hand, runs 300 ms and
// checks what is drawn: the bar's origin fixed in the controller's frame (its
// place on the laser and its distance; pushed along the line from the
// controller by the push), the cat upright, its scruff straight below the
// bar, turned by the controller's heading since the grab. dr.release() lets
// go: the fall starts where it was drawn, the root back in the world there.
async function pressBar(p, H0 = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] }) {
p.poses['/user/hand/right'] = H0;
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', { CapsLock: true, NumLock: true });
await p.advance(500);
assert(p.S.stats().drag?.phase === 'drag' && p.S.stats().carry === '/user/hand/right', 'dragging, carried by the right hand');
const heading = (q) => { const f = qr(q, [0, 0, -1]); return Math.atan2(f[0], f[2]) * 180 / Math.PI; };
const wrap = (a) => ((a + 540) % 360) - 180;
const drawn = (w) => {
const sg = p.lastMsg().scene_graph;
const frame = findSg(sg, (n) => n.type === 'transform' && n.properties.scale?.[0] === 1 && n.children?.[0]?.type === 'rendermodel');
const outer = findSg(sg, (n) => n.type === 'transform' && n.children?.[0]?.type === 'head-facing-transform');
return { bar: w.get('system.systemui::sfui-vr-pet-handle').p, cat: w.get(frame.properties.sgid), lift: outer.properties.translation[1] };
};
const dr = { H0, g0: null, cat0: null, last: null };
dr.check = (H, w, label, { body = true, push = 0 } = {}) => {
const d = drawn(w);
const g = qc(qinv(H), { p: d.bar, q: [1, 0, 0, 0] }).p; // the bar's origin in the controller's frame
dr.g0 ??= g;
const l0 = Math.hypot(...dr.g0), want = dr.g0.map((v) => v * (1 + push / l0));
const off = Math.hypot(...g.map((v, k) => v - want[k]));
assert(off < 0.002, `${label}: the bar keeps its place on the laser (${(off * 1000).toFixed(1)} mm off in the controller's frame; ${Math.hypot(...g).toFixed(3)} m from it, want ${Math.hypot(...want).toFixed(3)})`);
if (!body) return d;
assert(near(d.lift, 0.15, 1e-9), `${label}: dangling, the bar at liftDangle (${d.lift})`);
dr.cat0 ??= { yaw: heading(d.cat.q), hand: heading(H.q) };
const up = qr(d.cat.q, [0, 1, 0]);
assert(up[1] > Math.cos(Math.PI / 180), `${label}: the cat upright (tilt ${(Math.acos(Math.min(1, up[1])) * 180 / Math.PI).toFixed(1)} deg)`);
const dx = Math.hypot(d.cat.p[0] - d.bar[0], d.cat.p[2] - d.bar[2]);
assert(dx < 0.002 && near(d.bar[1] - d.cat.p[1], 0.15, 0.002), `${label}: the scruff straight below the bar (${(dx * 1000).toFixed(1)} mm aside, ${(d.bar[1] - d.cat.p[1]).toFixed(3)} m below)`);
const dyaw = wrap(heading(d.cat.q) - dr.cat0.yaw - (heading(H.q) - dr.cat0.hand));
assert(Math.abs(dyaw) < 1, `${label}: turned by the controller's heading (off by ${dyaw.toFixed(1)} deg)`);
dr.last = d;
return d;
};
dr.check(H0, p.vrc.world(), 'grabbed', { body: false });
dr.move = async (H, label, o) => { p.poses['/user/hand/right'] = H; await p.advance(300); return dr.check(H, p.vrc.world(), label, o); };
dr.release = async () => {
const d = dr.last;
p.byId('sfui-vr-pet-handle-content').dispatch('mouseup', { CapsLock: true, NumLock: true });
await p.advance(1000 / 24 * 3);
const st = p.S.cat.state(); // (falling, or landed already when let go low)
assert(!st.held && !p.S.stats().drag && Math.hypot(st.x - d.cat.p[0], st.z - d.cat.p[2]) < 0.15,
`released below where it was drawn (${st.x.toFixed(3)} ${st.z.toFixed(3)} vs ${d.cat.p[0].toFixed(3)} ${d.cat.p[2].toFixed(3)})`);
const root = p.vrc.world().get('system.systemui::sfui-vr-pet');
assert(!p.S.stats().carry && Math.hypot(root.p[0] - st.x, root.p[2] - st.z) < 0.05, `the root back in the world at the cat (${root.p.map((v) => v.toFixed(3))})`);
};
return dr;
}
// The drag twist test for a model: the cat (the default) or another animal
// (switched to first; its bar stands higher, the grab point is the same).
const twistTest = (model) => async () => {
const p = await running();
if (model !== catalog.default) {
assert(catalog.models[model], `${model} in the catalog (${catalog.order})`);
const r = p.S.api.setModel({ id: model });
assert(r.model === model && !r.error, JSON.stringify(r));
await p.advance(4000);
}
const dr = await pressBar(p);
const tw = [[[0, 1, 0], 40], [[1, 0, 0], 35], [[0, 0, 1], 70], [[1, 0, 0], -50], [[0, 1, 0], -60], [[0, 0, 1], -80]];
let H = dr.H0;
for (let i = 0; i <= tw.length * 2; i++) {
// twists about the controller (yaw, pitch, roll), each step also carried along a little
if (i > 0) { const [ax, deg] = tw[(i - 1) % tw.length]; H = { p: H.p.map((v, k) => v + [0.03, -0.02, -0.04][k]), q: qm(H.q, qaxis(ax, deg)) }; }
await dr.move(H, `step ${i}`);
}
// let go: the fall starts where it was drawn
await dr.release();
};
test('laser drag: the grab point stays put on the laser through controller twists; the cat hangs upright below it, turned by the heading', twistTest(catalog.default));
test('laser drag of the Shiba: the grab point stays put through the twists, it hangs upright below it', twistTest('shiba'));
test('laser drag, pure pitch ±40°: the bar keeps its place on the laser and its distance from the controller (also between ticks)', async () => {
const p = await running();
const dr = await pressBar(p, { p: [0.1, 1.1, 1.0], q: qaxis([1, 0, 0], -30) }); // pointing down at the cat
// tilted at the wrist (10 cm behind the controller's origin, 3 cm below):
// the controller's origin moves a little too, as a real tilt does
const wrist = qc(dr.H0, { p: [0, -0.03, 0.10], q: [1, 0, 0, 0] });
for (const deg of [10, 20, 30, 40, 20, 0, -10, -20, -30, -40, -20, 0, 40, -40, 0]) {
const H = qc(qc(wrist, { p: [0, 0, 0], q: qaxis([1, 0, 0], deg) }), qc(qinv(wrist), dr.H0));
// as vrcompositor draws it with the new pose, before the next tick
p.poses['/user/hand/right'] = H;
dr.check(H, p.vrc.world(), `pitch ${deg}° (before the tick)`, { body: false });
await dr.move(H, `pitch ${deg}°`);
}
await dr.release();
});
test('laser drag: the thumbstick push moves it along the laser; let go, it falls from the pushed point; the push resets', async () => {
const p = await running();
const dr = await pressBar(p);
const gen = p.S.stats().pushSent['reset-generation'];
assert(p.S.stats().pushSent['is-active'] === true && p.S.stats().pushSent['base-distance'] > 0.3,
`push active while dragged, its base distance the grab point's (${JSON.stringify(p.S.stats().pushSent)})`);
const bar = findSg(p.lastMsg().scene_graph, (n) => n.properties?.id === 'system.systemui::sfui-vr-pet-handle');
assert(p.S.stats().pushSent['event-panel-sgid'] === bar.properties.sgid, 'the push\'s event panel: the bar');
p.vrc.push = 0.6; // pushed away 0.6 m (vrcompositor, the thumbstick)
const H = { p: dr.H0.p.map((v, k) => v + [0.05, 0, -0.05][k]), q: qm(dr.H0.q, qaxis([0, 1, 0], 25)) };
await dr.move(H, 'pushed', { push: 0.6 });
assert(near(p.S.stats().drag.push, 0.6, 1e-3),`the push known (${p.S.stats().drag.push})`);
await dr.release();
assert(p.S.stats().pushSent['reset-generation'] > gen && p.S.stats().pushSent['is-active'] === false && !p.S.stats().carry, 'push reset, inactive; not carried');
assert(p.vrc.push === 0, 'vrcompositor: the push back to 0');
});
test('X: press and release on it hide; a press released elsewhere or leaving it does not', async () => {
const p = await running();
const R = { CapsLock: true, NumLock: true };
const x = p.byAttr('data-act', 'close'), bar = p.byId('sfui-vr-pet-handle-content');
// pressed, released elsewhere (the bar)
x.dispatch('mousedown', R);
assert(x.hasAttribute('data-armed'), 'pressed look while pressed');
bar.dispatch('mouseup', R);
assert(!x.hasAttribute('data-armed'), 'pressed look gone on release');
x.dispatch('click', R);
assert(!p.S.hidden, 'released elsewhere: shown (a stray click does nothing)');
// pressed, left, released on it
x.dispatch('mousedown', R); x.dispatch('mouseleave', R); x.dispatch('mouseup', R); x.dispatch('click', R);
assert(!p.S.hidden, 'left before the release: shown');
// a release on it without a press on it
x.dispatch('mouseup', R);
assert(!p.S.hidden, 'release without a press: shown');
// press and release on it
const e = p.click(x);
assert(p.S.hidden && e.stopped, 'press + release on the X: hidden (the click does not reach the dashboard)');
});
test('only the laser drag holds it: hands at the scruff do not, a falling cat is not caught', async () => {
const p = page();
p.inject();
await p.advance(500);
const sc = p.S.cat.zone('scruff');
p.poses['/user/hand/left'] = { p: [sc.x, sc.y, sc.z], q: [1, 0, 0, 0] };
p.poses['/user/hand/right'] = { p: [sc.x, sc.y + 0.02, sc.z], q: [1, 0, 0, 0] };
await p.advance(2000);
assert(!p.S.cat.state().held && p.S.root.getAttribute('parent-path') === null, 'not held, root in the world');
// a falling cat: a press on the bar does not catch it
p.poses['/user/hand/left'] = p.poses['/user/hand/right'] = null;
p.S.cat.command('drop');
await p.advance(100);
assert(p.S.cat.state().falling, 'falling');
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', { CapsLock: true, NumLock: true });
assert(!p.S.stats().drag, 'no drag of a falling cat');
});
test('fresh page with a stored hidden cat: built but not in the page, no timers, no sends', async () => {
const p = page({ store: { x: 0.3, z: 0.2, yaw: 0, pose: 'sit', hidden: true } });
const r = p.inject();
assert(r === 'hidden', `result hidden (${r})`);
assert(p.S.hidden && !p.S.root.isConnected, 'root out of the page');
assert(p.activeTimers() === 0 && p.stats.total === 0, `no timers (${p.activeTimers()}), no sends (${p.stats.total})`);
await p.advance(10000);
assert(p.stats.total === 0 && p.activeTimers() === 0, 'still nothing after 10 s');
assert(p.inject() === 'unchanged', 'next injection: unchanged');
const s = p.S.api.show();
assert(s.was === 'hidden' && !s.summoned, `shown at its spot (${JSON.stringify(s)})`);
assert(near(p.S.cat.state().x, 0.3, 1e-9) && p.S.cat.state().pose === 'sit', 'stored spot and pose');
await p.advance(2000);
assert(p.stats.total > 0 && p.S.root.isConnected, 'sending once shown');
assert(p.stored.hidden === false && p.stored.pose === 'sit', `store: hidden false, pose kept (${JSON.stringify(p.stored)})`);
});
test('store round-trip: core saves merge, hidden kept', async () => {
const p = await running();
p.S.api.hide();
assert(p.stored.hidden === true, 'hidden stored');
const saved = { ...p.stored };
// A new page (SteamVR restart) seeded from that value.
const q = page({ store: saved });
assert(q.inject() === 'hidden', 'restart: still hidden');
q.S.api.show();
await q.advance(1000);
q.S.cat.command('sit');
await q.advance(4000);
assert(q.stored.hidden === false && q.stored.pose === 'sit', `core save merged (${JSON.stringify(q.stored)})`);
assert(q.S.api.status().hidden === false && q.S.api.status().pose === 'sit', 'status');
});
// The menu: its panel in the last message, its transform.
const menuSg = (p) => findSg(p.lastMsg().scene_graph, (n) => n.properties?.id === 'system.systemui::sfui-vr-pet-menu');
const menuXf = (p) => findSg(p.lastMsg().scene_graph, (n) => n.type === 'transform' && n.children?.[0]?.properties?.id === 'system.systemui::sfui-vr-pet-menu');
const checkOf = (p, id) => p.byAttr('data-model', id).children.find((c) => c.getAttribute('class') === 'sfui-vr-pet-check');
test('⋯ opens the menu above the bar (one send), again closes it (one send)', async () => {
const p = await running();
const more = p.byAttr('data-act', 'menu');
let n = p.stats.total;
p.click(more);
assert(p.S.stats().menuOpen && p.stats.total - n === 1, `open, one send (${p.stats.total - n})`);
const msg = p.lastMsg();
const m = findSg(msg.scene_graph, (x) => x.properties?.id === 'system.systemui::sfui-vr-pet-menu');
const t = findSg(msg.scene_graph, (x) => x.type === 'transform' && x.children?.[0] === m);
assert(m && t && menuXf(p), 'menu panel in the send');
const M = m.properties;
assert(near(t.properties.translation[1], 0.11 * 30 / 132 + 0.01, 1e-9) && near(t.properties.translation[2], 0.02, 1e-9), `1 cm above the bar, 2 cm nearer (${t.properties.translation})`);
assert(M.width === 0.13 && M.origin[1] === -1 && M['only-visible-with-laser'] === true && M.interactive === true && M['is-grab-handle'] === false, 'menu panel props');
const facing = findSg(msg.scene_graph, (x) => x.type === 'head-facing-transform');
assert(facing.children.includes(t), 'under the head-facing transform (carried with the drag)');
const rows = catalog.order.map((id) => p.byAttr('data-model', id));
assert(rows.every(Boolean), 'a row per coat');
const img = rows[0].children.find((c) => c.tagName === 'IMG');
assert(img?.getAttribute('src').startsWith('data:image/png;base64,'), 'thumbnail');
assert(checkOf(p, catalog.default).hasAttribute('data-on') && !checkOf(p, 'tuxedo').hasAttribute('data-on'), 'check on the current coat');
assert(['summon', 'sit', 'lie', 'sleep'].every((c) => p.byAttr('data-cmd', c)), 'Summon, Sit, Lie, Sleep');
// a line between the coats and the animals, one before the commands
const kids = p.byId('sfui-vr-pet-menu-content').children, cls = (e) => e.getAttribute('class');
const seps = kids.map((e, i) => (cls(e) === 'sfui-vr-pet-sep' ? i : -1)).filter((i) => i >= 0);
const groups = catalog.order.map((id) => catalog.models[id].group);
assert(seps.length === new Set(groups).size, `a separator per group (${seps.length}, groups ${[...new Set(groups)]})`);
for (const i of seps.slice(0, -1)) {
const before = kids[i - 1].getAttribute('data-model'), after = kids[i + 1].getAttribute('data-model');
assert(catalog.models[before].group !== catalog.models[after].group, `between groups (${before} | ${after})`);
}
n = p.stats.total;
p.click(more);
assert(!p.S.stats().menuOpen && p.stats.total - n === 1 && !menuSg(p), 'closed, one send, gone from the scene graph');
const content = p.byId('sfui-vr-pet-menu-content');
assert(content.isConnected && content.style.visibility === 'hidden', 'closed: still in the page (its transform too), hidden');
assert(p.byId('sfui-vr-pet-menu').parent.parent?.tagName === 'VSG-HEAD-FACING-TRANSFORM', 'its transform under the head-facing one');
});
test("the menu's UV slot is valid when it opens, without a layout at open", async () => {
const p = page();
assert(/^patched/.test(p.inject()), 'injected');
const menu = p.byId('sfui-vr-pet-menu-content'), fp = p.fpOf('sfui-vr-pet-menu');
assert(fp && p.slotValid(fp), `valid from the first layout (${JSON.stringify(fp?.m_Rect)})`);
await p.advance(3000);
assert(!p.S.stats().menuOpen && menu.isConnected && menu.style.visibility === 'hidden', 'closed: connected, hidden');
const r = menu.getBoundingClientRect();
assert(r.width === 240 && r.height > 0, `a non-zero rect (${r.width} x ${r.height})`);
p.layout();
assert(p.slotValid(fp) && fp.m_Rect.width === r.width && fp.m_Rect.height === r.height, 'valid after a layout while closed');
const more = p.byAttr('data-act', 'menu');
let n = p.stats.total, f = p.stats.force;
p.click(more);
assert(p.S.stats().menuOpen && p.stats.total - n === 1 && p.stats.force === f, `open: one send, no forced layout (${p.stats.total - n}, force ${p.stats.force - f})`);
assert(menu.style.visibility === '' && p.slotValid(fp), 'visible, slot valid');
const M = menuSg(p).properties;
assert(M['embedded-uv-index'] === fp.idx && near(M.uv_max[0] - M.uv_min[0], 240 / 1280, 1e-9), 'its panel: the slot, its rect');
n = p.stats.total;
p.click(more);
assert(!p.S.stats().menuOpen && p.stats.total - n === 1 && p.stats.force === f && !menuSg(p), 'close: one send, out of the scene graph');
assert(menu.isConnected && menu.style.visibility === 'hidden', 'close: hidden, still connected');
p.layout();
assert(p.slotValid(fp), 'still valid while closed');
p.click(more);
assert(p.S.stats().menuOpen && menuSg(p) && p.slotValid(fp) && p.stats.force === f, 'reopened: in the send, valid');
});
// The page rects (left, top, right, bottom) of our textures: the bar with the controls, the menu.
const rectOf = (p, id) => { const r = p.byId(id).getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; };
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
const ownRects = (p) => {
const b = rectOf(p, 'sfui-vr-pet-handle-content'), c = rectOf(p, 'sfui-vr-pet-controls-content');
return { bar: { left: Math.min(b.left, c.left), top: b.top, right: Math.max(b.right, c.right), bottom: c.bottom }, menu: rectOf(p, 'sfui-vr-pet-menu-content') };
};
test('page regions: clear of foreign panels, the keyboard strip band, each other; inside the page', async () => {
const foreign = [];
const p = page({ setup(G, doc) {
const fp = (name, rect, extra = {}) => {
const el = doc.createElement('div');
const f = { props: { debug_name: name }, isExternal: false, m_Rect: rect, getSGID: () => name, getEmbeddedIndex: () => 900, getCurrentRootElement: () => el, ...extra };
G.SGApp.m_mapPanels.set(name, f);
return f;
};
// where the bar would go (bottom right, above the band), measured by m_Rect only (its root 0 x 0)
foreign.push(fp('foreign-bar-spot', { x: 1080, y: 1150, width: 200, height: 160 }).m_Rect);
// a panel with a root element rect, where the menu would go next
const el = doc.createElement('div');
Object.assign(el.style, { position: 'fixed', left: '700px', top: '640px', width: '300px', height: '500px' });
doc.body.appendChild(el);
fp('foreign-menu-spot', { x: 0, y: 0, width: 0, height: 0 }, { getCurrentRootElement: () => el });
foreign.push({ x: 700, y: 640, width: 300, height: 500 });
// ignored: an external panel and another overlay's popup covering the page
fp('external', { x: 0, y: 0, width: 1280, height: 1440 }, { isExternal: true });
fp('popup', { x: 0, y: 0, width: 1280, height: 1440 }, { props: { debug_name: 'popup', overlay_key: 'valve.steam.gamepadui.bar' } });
// a panel node in the keyboard strip band
const strip = doc.createElement('vsg-node');
strip.setAttribute('vsg-type', 'panel');
const sc = doc.createElement('div');
Object.assign(sc.style, { position: 'fixed', left: '2px', top: '1326px', width: '1274px', height: '110px' });
strip.getBoundingClientRect = () => sc.getBoundingClientRect();
strip.appendChild(sc);
doc.body.appendChild(strip);
foreign.push({ x: 2, y: 1326, width: 1274, height: 110 });
} });
assert(/^patched/.test(p.inject()), 'injected');
await p.advance(1000);
const { bar, menu } = ownRects(p);
const band = { left: 0, top: 1440 - 120, right: 1280, bottom: 1440 };
const others = [...foreign.map((r) => ({ left: r.x, top: r.y, right: r.x + r.width, bottom: r.y + r.height })), band, { left: 0, top: 0, right: 1280, bottom: 600 }];
for (const [name, r] of Object.entries({ bar, menu })) {
assert(r.right > r.left && r.left >= 0 && r.top >= 1 && r.right <= 1280 && r.bottom <= 1440, `${name} inside the page (${JSON.stringify(r)})`);
for (const o of others) assert(!overlaps(r, o), `${name} ${JSON.stringify(r)} clear of ${JSON.stringify(o)}`);
}
assert(!overlaps(bar, menu), `bar and menu apart (${JSON.stringify(bar)} ${JSON.stringify(menu)})`);
assert(p.slotValid(p.fpOf('sfui-vr-pet-menu')) && p.slotValid(p.fpOf('sfui-vr-pet-handle')), 'slots valid');
});
test('page regions: claimed from a shared allocator when there is one; moved by it; released on dispose', async () => {
const claims = new Map(), released = [];
const p = page({ setup(G) {
G.__sfuiRegions = {
claim(owner, w, h, o) { const r = { x: owner === 'vr-pet:bar' ? 40 : 300, y: 700, w, h }; claims.set(owner, { r, o }); return r; },
release(owner) { released.push(owner); },
};
} });
assert(/^patched/.test(p.inject()), 'injected');
await p.advance(500);
assert(claims.has('vr-pet:bar') && claims.has('vr-pet:menu'), `claimed (${[...claims.keys()]})`);
const b = claims.get('vr-pet:bar').r;
assert(near(rectOf(p, 'sfui-vr-pet-handle-content').top, 700, 1e-9) && near(rectOf(p, 'sfui-vr-pet-controls-content').bottom, 700 + b.h, 1e-9), 'the bar and controls in the bar claim');
assert(near(rectOf(p, 'sfui-vr-pet-menu-content').left, 300, 1e-9), 'the menu in its claim');
claims.get('vr-pet:menu').o.onMove({ x: 500, y: 100, w: 240, h: 10 });
assert(near(rectOf(p, 'sfui-vr-pet-menu-content').left, 500, 1e-9) && near(rectOf(p, 'sfui-vr-pet-menu-content').top, 100, 1e-9), 'moved by onMove');
assert(p.fpOf('sfui-vr-pet-menu').m_Rect.x === 500, 'laid out again after the move');
p.S.dispose();
assert(released.includes('vr-pet:bar') && released.includes('vr-pet:menu'), `released (${released})`);
});
test('menu picks: a coat (setModel, check moves), a command; each closes the menu', async () => {
const p = await running();
const more = p.byAttr('data-act', 'menu');
p.click(more);
p.click(p.byAttr('data-model', 'cream'));
assert(!p.S.stats().menuOpen && p.S.api.status().model === 'cream' && p.stored.model === 'cream', 'coat picked, closed');
assert(checkOf(p, 'cream').hasAttribute('data-on') && !checkOf(p, catalog.default).hasAttribute('data-on'), 'check moved');
await p.advance(500);
p.click(more);
p.click(p.byAttr('data-cmd', 'sit'));
assert(!p.S.stats().menuOpen, 'closed after the command');
await p.advance(4000);
assert(p.S.cat.state().pose === 'sit', `sits (${p.S.cat.state().pose})`);
// a row pressed but released on another does nothing
p.click(more);
const R = { CapsLock: true, NumLock: true };
p.byAttr('data-cmd', 'lie').dispatch('mousedown', R);
p.byAttr('data-cmd', 'sleep').dispatch('mouseup', R);
assert(p.S.stats().menuOpen && p.S.cat.state().goal === 'sit', 'press on one row, release on another: nothing');
});
test('menu closes: a press elsewhere on the page, 1 s after the laser left, on hide; ⋯ inert while dragged', async () => {
const p = await running();
const more = p.byAttr('data-act', 'menu'), menu = p.byId('sfui-vr-pet-menu-content'), ctl = p.byId('sfui-vr-pet-controls-content');
const R = { CapsLock: true, NumLock: true };
p.click(more);
p.dash.dispatch('mousedown', R);
assert(!p.S.stats().menuOpen, 'a press on a dashboard panel closes it');
p.click(more);
p.byAttr('data-model', 'blue').dispatch('mousedown', R);
assert(p.S.stats().menuOpen, 'a press inside does not');
p.byAttr('data-model', 'blue').dispatch('mouseleave', R); // (disarmed)
menu.dispatch('mouseleave', R);
await p.advance(900);
assert(p.S.stats().menuOpen, 'still open 0.9 s after the laser left');
ctl.dispatch('mouseenter', R);
await p.advance(500);
assert(p.S.stats().menuOpen, 'back on the controls: stays');
ctl.dispatch('mouseleave', R);
await p.advance(1100);
assert(!p.S.stats().menuOpen, 'closed 1 s after the laser left');
p.click(more);
const n = p.stats.total;
p.S.api.hide();
assert(!p.S.stats().menuOpen && p.stats.total - n === 1, `hide: closed, one send (${p.stats.total - n})`);
p.S.api.show();
await p.advance(1000);
assert(!p.S.stats().menuOpen && !menuSg(p), 'shown again: closed');
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', R);
await p.advance(200);
p.click(more, 'left');
assert(!p.S.stats().menuOpen, '⋯ during a drag: nothing');
});
// The rendermodel sources in the last message (mounted frames).
const sources = (p) => {
const out = [];
const walk = (n) => { if (!n || typeof n !== 'object') return; if (Array.isArray(n)) { n.forEach(walk); return; } if (n.type === 'rendermodel') out.push(n.properties.source); walk(n.children); };
walk(p.lastMsg()?.scene_graph);
return out;
};
const dirOf = (id) => catalog.models[id].dir;
test('models: the catalog, default coat mounted; api v2', async () => {
const p = await running();
assert(p.S.api.version === 2 && typeof p.S.api.models === 'function' && typeof p.S.api.setModel === 'function', 'api v2 { models, setModel }');
const l = p.S.api.models();
assert(l.current === catalog.default && l.models.length === catalog.order.length && l.models[0].id === catalog.order[0], JSON.stringify(l));
const src = sources(p);
assert(src.length > 0 && src.every((x) => x.startsWith(`${dirOf(catalog.default)}/`)), `default coat's frames (${src[0]})`);
assert(p.S.api.status().model === catalog.default, 'status.model');
});
test('setModel: one send without frames, then gradual remount from the new dir; stored; the core goes on', async () => {
const p = await running();
const before = p.stats.total, st0 = p.S.cat.state();
const r = p.S.api.setModel({ id: 'tuxedo' });
assert(r.model === 'tuxedo' && r.was === catalog.default && !r.error, JSON.stringify(r));
assert(p.stats.total - before === 1 && sources(p).length === 0, `one send without frames (${p.stats.total - before}, ${sources(p).length})`);
assert(p.stored.model === 'tuxedo', `stored (${JSON.stringify(p.stored)})`);
await p.advance(1000 / 24 + 1);
const n1 = p.S.stats().frames;
assert(n1 > 0 && n1 <= 3, `at most 3 mounts per tick (${n1})`);
await p.advance(3000);
const src = sources(p);
assert(src.length > 3 && src.every((x) => x.startsWith(`${dirOf('tuxedo')}/`)), `tuxedo's frames (${src.length}, ${src.find((x) => !x.startsWith(dirOf('tuxedo')))})`);
const st = p.S.cat.state();
assert(Math.hypot(st.x - st0.x, st.z - st0.z) < 1.5 && p.S.api.status().model === 'tuxedo', 'same cat (spot), model in status');
// the slow path (the dashboard's DOM walk) builds the same source
const node = [...p.S.root.walk()].find((n) => n.buildNode?.({})?.[1]?.type === 'rendermodel');
assert(node.buildNode({})[1].properties.source.startsWith(`${dirOf('tuxedo')}/`), 'DOM node source');
const again = p.S.api.setModel({ id: 'tuxedo' });
assert(again.was === 'tuxedo', 'same model: no-op');
});
test('setModel: unknown -> error, unchanged; hidden -> only stored, shown with it', async () => {
const p = await running();
const n = p.stats.total;
const bad = p.S.api.setModel({ id: 'lion' });
assert(bad.error && bad.model === catalog.default && p.stats.total === n, `unknown: error, no send (${JSON.stringify(bad)})`);
p.S.api.hide();
const sent = p.stats.total;
const r = p.S.api.setModel({ id: 'snow' });
assert(r.model === 'snow' && r.hidden && p.stats.total === sent && p.activeTimers() === 0, 'hidden: no send, no timers');
assert(p.stored.model === 'snow' && p.stored.hidden === true, JSON.stringify(p.stored));
p.S.api.show();
await p.advance(3000);
assert(sources(p).length > 0 && sources(p).every((x) => x.startsWith(`${dirOf('snow')}/`)), 'shown as snow');
});
test('stored model at injection; an unknown stored model falls back to the default (logged)', async () => {
const p = page({ store: { model: 'blue' } });
assert(/^patched \(blue,/.test(p.inject()), 'patched with blue');
await p.advance(2000);
assert(sources(p).every((x) => x.startsWith(`${dirOf('blue')}/`)) && sources(p).length > 0, 'blue frames');
const q = page({ store: { model: 'dragon' } });
assert(new RegExp(`^patched \\(${catalog.default},`).test(q.inject()), 'default');
assert(q.S.log.some((l) => l.includes('unknown stored model')), 'logged');
});
// Another animal (a frame set of its own): the first one in the catalog.
const animal = catalog.order.find((id) => catalog.models[id].frames !== catalog.models[catalog.default].frames);
// The grip bar's lift above the frames' origin in the last message.
const liftSent = (p) => findSg(p.lastMsg().scene_graph, (n) => n.type === 'transform' && n.children?.[0]?.type === 'head-facing-transform')?.properties.translation[1];
test('setModel to another animal: its own frames and core at the same spot, one send, gradual remount, bar at its height', async () => {
assert(animal, `an animal in the catalog (${catalog.order})`);
const p = await running();
p.S.cat.command('sit');
await p.advance(4000);
const st0 = p.S.cat.state(), core0 = p.S.cat, before = p.stats.total;
assert(near(liftSent(p), 0.40, 1e-9), `cat: bar 0.40 m up (${liftSent(p)})`);
const r = p.S.api.setModel({ id: animal });
assert(r.model === animal && !r.error, JSON.stringify(r));
assert(p.stats.total - before === 1 && sources(p).length === 0, `one send without frames (${p.stats.total - before})`);
const bk = catalog.frameSets[catalog.models[animal].frames];
assert(p.S.cat !== core0 && Object.keys(p.S.frames).sort().join() === Object.keys(bk.clips).sort().join(), 'its frames, a new core');
const st = p.S.cat.state();
assert(near(st.x, st0.x, 1e-3) && near(st.z, st0.z, 1e-3) && near(st.yaw, st0.yaw, 1e-3), 'same spot and heading');
assert(st.pose === (bk.clips.sit ? 'sit' : 'stand'), `its pose if it has it (${st.pose})`);
assert(p.stored.model === animal, 'stored');
for (let i = 0; i < 20 && !p.S.stats().frames; i++) await p.advance(5); // up to the first tick that mounts
assert(p.S.stats().frames > 0 && p.S.stats().frames <= 3, `at most 3 mounts per tick (${p.S.stats().frames})`);
await p.advance(4000);
const src = sources(p);
assert(src.length > 3 && src.every((x) => x.startsWith(`${dirOf(animal)}/`) && bk.clips[x.slice(dirOf(animal).length + 1).replace(/_\d+\.obj$/, '')]),
`its frames (${src.length}, ${src.find((x) => !x.startsWith(dirOf(animal)))})`);
assert(near(liftSent(p), bk.height + 0.10, 1e-9), `bar at its height + 0.10 m (${liftSent(p)})`);
assert(p.S.api.status().model === animal, 'status.model');
// the menu's commands drive the new core
p.click(p.byAttr('data-act', 'menu'));
p.click(p.byAttr('data-cmd', 'lie'));
await p.advance(5000);
assert(p.S.cat.state().pose === 'lie', `lies (${p.S.cat.state().pose})`);
// and back to the cat
const back = p.S.api.setModel({ id: catalog.default });
await p.advance(4000);
assert(back.model === catalog.default && sources(p).every((x) => x.startsWith(`${dirOf(catalog.default)}/`)) && sources(p).length > 3, 'back to the cat');
assert(near(liftSent(p), 0.40, 1e-9), 'bar 0.40 m up again');
});
test('setModel to another animal during a laser drag: the drag is let go, it stands where it was lifted', async () => {
const p = await running();
const st0 = p.S.cat.state();
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', { CapsLock: true, NumLock: true });
await p.advance(300);
assert(p.S.stats().drag, 'dragging');
p.S.api.setModel({ id: animal });
const st = p.S.cat.state();
assert(!p.S.stats().drag && !st.held && !st.falling && st.pose === 'stand', `let go, standing (${st.pose})`);
assert(Math.hypot(st.x - st0.x, st.z - st0.z) < 0.2, 'where it was lifted');
await p.advance(3000);
assert(sources(p).length > 0, 'shown');
});
test('setModel to another animal while hidden: switched, no sends; shown as it; stored at injection', async () => {
const p = await running();
p.S.api.hide();
const n = p.stats.total;
const r = p.S.api.setModel({ id: animal });
assert(r.hidden && p.stats.total === n && p.activeTimers() === 0, 'hidden: no send, no timers');
p.S.api.show();
await p.advance(3000);
assert(sources(p).length > 0 && sources(p).every((x) => x.startsWith(`${dirOf(animal)}/`)), `shown as ${animal}`);
const q = page({ store: { model: animal } });
assert(new RegExp(`^patched \\(${animal},`).test(q.inject()), 'patched with it');
await q.advance(3000);
assert(sources(q).every((x) => x.startsWith(`${dirOf(animal)}/`)) && sources(q).length > 0, 'its frames');
});
test('re-injection of a new version: the old one disposed (no second listener, timer, hook, style or panel)', async () => {
const p = await running();
const base = { listeners: p.listeners(), timers: p.activeTimers(), styles: p.doc.head.children.length, slots: p.embedded.filter(Boolean).length };
const old = p.S;
old.sig = 'an older version';
assert(/^patched/.test(p.inject()), 'injected again');
await p.advance(2000);
assert(p.S !== old && !old.root.isConnected, 'the old root gone');
const now = { listeners: p.listeners(), timers: p.activeTimers(), styles: p.doc.head.children.length, slots: p.embedded.filter(Boolean).length };
assert(JSON.stringify(now) === JSON.stringify(base), `the same as one injection (${JSON.stringify(now)} vs ${JSON.stringify(base)})`);
assert(p.doc.body.children.filter((e) => e.id === 'sfui-vr-pet').length === 1, 'one root');
const send = Object.getPrototypeOf(p.mailbox).SendMessage;
assert(send.__orig && !send.__orig.__orig, 'SendMessage hooked once');
assert(p.sock.send.__sfuiPetOrig && !p.sock.send.__sfuiPetOrig.__sfuiPetOrig, 'the socket wrapped once');
assert(p.stats.fast > 0 && p.S.stats().captured, 'fast sends again');
});
test('unpatch.js during a drag: the pet, its listeners, timers, hook, socket wrapper, style and panels gone; one send without it', async () => {
const p = await running();
const R = { CapsLock: true, NumLock: true };
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', R);
p.click(p.byAttr('data-act', 'menu'), 'left');
await p.advance(300);
const root = p.S.root, n = p.stats.total;
assert(p.unpatch() === 'unpatched' && !('__sfuiPet' in p.G), 'unpatched, global gone');
assert(!root.isConnected && p.listeners() === 0 && p.activeTimers() === 0, `nothing left (${p.listeners()} listeners, ${p.activeTimers()} timers)`);
assert(!Object.getPrototypeOf(p.mailbox).SendMessage.__orig && !p.sock.send.__sfuiPetOrig, 'hook and socket wrapper removed');
assert(p.doc.head.children.length === 0 && p.embedded.every((e) => e === null), 'style and embedded panels removed');
assert(!p.doc.all.some((e) => e.id?.startsWith('sfui-vr-pet') && e.isConnected), 'no element of ours in the page');
assert(p.stats.total - n === 1 && !findSg(p.lastMsg().scene_graph, (x) => x.properties?.sgid === +root.getAttribute('sgid')), 'one send without the pet');
await p.advance(5000);
assert(p.stats.total - n === 1, 'no sends after');
});
(async () => {
for (const [name, f] of tests) {
try { await f(); console.log(`ok ${name}`); } catch (e) { failed++; console.log(`FAIL ${name}: ${e.stack}`); }
}
if (failed) { console.log(`${failed} failed`); process.exit(1); }
console.log('all passed');
})();