Files
lhns--steam-frame-nix/modules/vr-keyboard-controllers/bridge-patch.js
T
Pierre Kisters af32cf72ce VR keyboard swipe with two controllers: the path from the controller bridge
With both lasers on the keyboard SteamVR forwards per poll only one laser's
movement, so the pressing one may get no touchmoves and its swipe became a
tap. keyboard.vr.swipe.twoHanded (default on; turns on the controller
bridge): a press is attributed to the hand whose laser hit is nearest to it
(within 30 px, the trigger as a tiebreak; without recent frames the first
frame after the press decides, within 60 px). The path comes from runs of
one source: the touch's own moves while they flow, that hand's laser hit
per bridge frame once they pause for 60 ms, touchmoves again if frames
pause. A touchend disagreeing with the laser during a bridge run ends at
the laser. Touchmoves on the other hand's laser move the attribution.
Without fresh frames: touch events only, as before. A second press during
a swipe stays Steam's tap (one text field, one word at a time).

The bridge sends full rate only on demand now: hub.js demand(ms) goes
back through the relay (CDP binding __sfuiCtlIn) to systemui's
__sfuiCtl.demand; the swipe patch asks from the press to the release.

Tests: gesture-input with the two-controller recording and synthetic bridge
frames (a pressing hand without touchmoves, attribution with both lasers on
the keyboard, a stale bridge, frames stopping mid-swipe, re-attribution,
interleaved hover and presses); hub demand.
2026-10-01 03:45:47 +02:00

178 lines
8.8 KiB
JavaScript

// The controller bridge (vr-keyboard-controllers.nix; patch name
// "vr-keyboard-controllers"), injected into SteamVR's systemui page (8087):
// both controllers relative to Steam's VR keyboard, streamed to Steam's
// SharedJSContext (relay.mjs -> hub.js). mkPatch convention plus one more
// argument: geometry.js.
//
// While SteamVR shows the keyboard (systemui mounts its overlay, mountedId
// "...gamepadui.keyboard", only then, also without the dashboard, in a
// transform scaled to its size; the overlay hangs from its top edge, 1
// overlay width = 1 unit there):
// - its pose: an empty vsg-transform of ours (id PROBE) in that transform,
// read with SteamVR's own SGQueryService.requestSGTransform every POLL_MS;
// for MOVE_QUIET_MS after it moved (drag, dashboard re-latch) frames say
// moving: true;
// - every TICK_MS both controllers: VRHTML.GetPose(hand, Standing) times the
// render model's "tip" component (SteamVR's laser origin) -> the tip and
// the laser's hit on the keyboard (geometry.js), the trigger from the
// render model's animated "trigger" component (null without one).
// Frames go out through the CDP binding __sfuiCtlOut(json) at TICK_MS while
// a hand is relevant (tip within NEAR m of the keyboard or trigger pulled) or
// Steam asked for it (S.demand(ms), hub.js demand: a laser press), else every
// IDLE_MS; one { keyboard: null } frame when the keyboard goes.
// Nothing at all while the keyboard is hidden (one DOM lookup per POLL_MS). Frame (hub.js adds page px):
// { seq, t, moving, keyboard: { width } | null,
// hands: { left, right: null | { tip: { u, v, d }, ray: { u, v, dist } | null,
// trigger: 0..1 | null } } }
// Never touches SteamVR's input or the lasers. Debugging: __sfuiCtl.log,
// __sfuiCtl.last (the last frame).
((find, sigs, opts, hooks, GEO) => {
const VERSION = 4;
const G = window;
const TICK_MS = 11, IDLE_MS = 1000, POLL_MS = 250, MOVE_QUIET_MS = 300, TIP_CACHE_MS = 5000;
const NEAR = 0.1; // m
const TRIGGER_DEG = 12.5; // frame controller: trigger component's full travel
const PROBE = 'sfui-ctl-keyboard';
const HANDS = ['left', 'right'];
const STANDING = 1; // ETrackingUniverseOrigin, as SteamVR's dashboard reads poses
const stamp = `${VERSION}:${GEO.VERSION} ${JSON.stringify(opts)}`;
if (G.__sfuiCtl?.stamp === stamp) return 'unchanged';
const V = G.VRHTML;
const missing = [
typeof V?.GetPose !== 'function' && 'VRHTML.GetPose',
typeof V?.VRRenderModels?.GetComponentStateForDevicePath !== 'function' && 'VRHTML.VRRenderModels.GetComponentStateForDevicePath',
typeof V?.VRProperties?.GetStringProperty !== 'function' && 'VRHTML.VRProperties.GetStringProperty',
typeof V?.NextSGID !== 'function' && 'VRHTML.NextSGID',
typeof V?.VROverlay?.ThisOverlayKey !== 'function' && 'VRHTML.VROverlay.ThisOverlayKey',
typeof G.SGQueryService?.requestSGTransform !== 'function' && 'SGQueryService.requestSGTransform',
typeof G.forceLayoutUpdate !== 'function' && 'forceLayoutUpdate',
].filter(Boolean);
if (missing.length) return `SteamVR internals changed, no controller bridge: missing ${missing.join(', ')}`;
try { G.__sfuiCtl?.dispose?.(); } catch { /* gone */ }
const S = G.__sfuiCtl = { stamp, log: [], kb: null, last: null };
const log = (...a) => { S.log.push([Math.round(performance.now()), ...a]); if (S.log.length > 100) S.log.shift(); };
let seq = 0, sentAt = -Infinity, wasRelevant = false;
const send = (f) => {
S.last = f;
sentAt = f.t;
try { G.__sfuiCtlOut?.(JSON.stringify(f)); } catch (e) { log('send', String(e)); }
};
// ---- the keyboard -----------------------------------------------------------
const fiberProps = (e) => {
const k = Object.keys(e).find((x) => x.startsWith('__reactFiber$'));
let f = k && e[k];
for (let i = 0; f && i < 6; f = f.return, i++) if (f.memoizedProps?.mountedId) return f.memoizedProps;
return null;
};
let scaled = null, probe = null;
function findScaled() {
if (scaled?.isConnected) return scaled;
scaled = null;
for (const e of document.querySelectorAll('[vsg-type="mountedscenegraph"]')) {
if (String(fiberProps(e)?.mountedId ?? '').endsWith('gamepadui.keyboard') && e.parentElement?.tagName === 'VSG-TRANSFORM') {
scaled = e.parentElement;
break;
}
}
return scaled;
}
function ensureProbe(parent) {
if (probe?.parentElement === parent) return;
probe?.remove();
probe = document.createElement('vsg-transform');
for (const [k, v] of Object.entries({ translation: '0 0 0', rotation: '1 0 0 0', scale: '1 1 1', sgid: V.NextSGID() }))
probe.setAttribute(k, String(v));
probe.id = PROBE;
parent.appendChild(probe);
G.forceLayoutUpdate();
}
const removeProbe = () => { if (probe) { probe.remove(); probe = null; G.forceLayoutUpdate(); } };
let pending = false, movedAt = -Infinity;
async function pollKeyboard() {
pending = true;
try {
const xf = await Promise.race([
G.SGQueryService.requestSGTransform(`${V.VROverlay.ThisOverlayKey()}::${PROBE}`),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 1000)),
]);
if (!probe) return;
let ow = 1;
try { ow = V.VROverlay.GetWidthInMeters(V.VROverlay.FindOverlay('valve.steam.gamepadui.keyboard')) || 1; } catch { /* 1 */ }
const kb = { translation: xf.translation, rotation: xf.rotation, width: xf.scale.x * ow };
const o = S.kb;
if (!o || Math.hypot(kb.translation.x - o.translation.x, kb.translation.y - o.translation.y, kb.translation.z - o.translation.z) > 0.002 ||
Math.abs(kb.rotation.w * o.rotation.w + kb.rotation.x * o.rotation.x + kb.rotation.y * o.rotation.y + kb.rotation.z * o.rotation.z) < 0.99998 ||
Math.abs(kb.width - o.width) > 0.002) movedAt = performance.now();
S.kb = kb;
} catch (e) { log('keyboard pose', String(e?.message ?? e)); } finally { pending = false; }
}
// ---- the controllers ------------------------------------------------------------
const models = {}; // hand -> { at, rm, tip }
function model(hand, now) {
const c = models[hand];
if (c && now - c.at < TIP_CACHE_MS) return c;
const path = `/user/hand/${hand}`;
let rm = null, tip = null;
try {
rm = V.VRProperties.GetStringProperty(path, 1003) || null; // Prop_RenderModelName_String
tip = rm ? V.VRRenderModels.GetComponentStateForDevicePath(rm, 'tip', path)?.xfTrackingToComponentLocal ?? null : null;
} catch { /* the device pose */ }
return (models[hand] = { at: now, rm, tip });
}
function trigger(hand, rm) {
try {
const q = rm && V.VRRenderModels.GetComponentStateForDevicePath(rm, 'trigger', `/user/hand/${hand}`)?.xfTrackingToComponentRenderModel?.rotation;
return q ? Math.min(1, GEO.rotationDegrees(q) / TRIGGER_DEG) : null;
} catch { return null; }
}
function handFrame(hand, now) {
const [p] = V.GetPose(`/user/hand/${hand}`, STANDING) || [];
if (!p?.bPoseIsValid) return null;
const m = model(hand, now);
const pose = GEO.tipPose(p.xfDeviceToAbsoluteTracking, m.tip);
return { tip: GEO.toKeyboard(pose.translation, S.kb), ray: GEO.rayHit(pose, S.kb), trigger: trigger(hand, m.rm) };
}
const relevant = (h) => !!h && ((Math.abs(h.tip.d) < NEAR && h.tip.u > -0.2 && h.tip.u < 1.2 && h.tip.v > -0.2 && h.tip.v < 0.7) ||
h.trigger >= 0.5);
// ---- loops ---------------------------------------------------------------------
let active = false, fastUntil = -Infinity;
S.demand = (ms) => { fastUntil = performance.now() + Math.max(0, Math.min(5000, +ms || 0)); };
const slow = setInterval(() => {
const parent = findScaled();
if (!parent) {
if (active) { send({ seq: ++seq, t: performance.now(), moving: false, keyboard: null, hands: { left: null, right: null } }); log('inactive'); }
active = false; S.kb = null;
removeProbe();
return;
}
ensureProbe(parent);
if (!active) log('active');
active = true;
if (!pending) pollKeyboard();
}, POLL_MS);
const fast = setInterval(() => {
if (!active || !S.kb) return;
const now = performance.now(), hands = {};
for (const h of HANDS) {
try { hands[h] = handFrame(h, now); } catch (e) { hands[h] = null; log('pose', String(e)); }
}
const rel = now < fastUntil || HANDS.some((h) => relevant(hands[h]));
if (rel || wasRelevant || now - sentAt >= IDLE_MS) {
send({ seq: ++seq, t: now, moving: now - movedAt < MOVE_QUIET_MS, keyboard: { width: S.kb.width }, hands });
}
wasRelevant = rel;
}, TICK_MS);
S.dispose = () => {
clearInterval(slow); clearInterval(fast);
if (active) send({ seq: ++seq, t: performance.now(), moving: false, keyboard: null, hands: { left: null, right: null } });
removeProbe();
};
return 'patched';
})