From a70bfe06e36d8e0dd571790eae884d0ce187e3b7 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Wed, 8 Jul 2026 16:47:00 -0400 Subject: [PATCH 01/10] Support multiple Steam Controllers on one Puck (per-unit interfaces) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026 Steam Controller Puck is a multi-receiver: two paired bodies each stream STATE on their OWN same-vid:pid vendor interface (verified on hardware — body#1 → MI_02, body#2 → MI_03). All interfaces share the Puck's serial, so serial can't tell the bodies apart; the streaming interface is the per-unit discriminator. The old sibling-fanout model assumed ONE body behind the Puck and collapsed every interface into one logical controller, so a second body was invisible. Fixes: - core manager.claimHidDeviceForSlot: only reroute a fan-out handle to a streaming sibling when the handle isn't itself streaming — a streaming handle is already its own unit. Prevents both bodies seating onto the first streaming sibling. +2 regression tests. - overlay finishGyroConnect: same guard, so selecting the 2nd controller no longer snaps back to the 1st. - overlay overlayControllerRows: one row per STREAMING interface (was a fan:vid:pid rollup that merged both bodies); hide silent siblings; append stable #1/#2 to duplicate-named rows since the shared Puck serial can't disambiguate them. - tools/steam-multi-probe.mjs (+ npm run steam-multi-probe): node-hid diagnostic that maps each body to its Puck interface. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW --- apps/overlay/src/js/app.js | 40 +++- package.json | 1 + packages/core/src/manager.js | 11 +- .../manager-claim-hid-device-for-slot.test.js | 42 ++++- tools/steam-multi-probe.mjs | 172 ++++++++++++++++++ 5 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 tools/steam-multi-probe.mjs diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 24fd229..0f489de 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -1208,7 +1208,12 @@ async function finishGyroConnect(device) { // Steam Controller Puck: the picked handle may be a sibling that never emits // STATE reports — designate the same-vid:pid interface that IS receiving them. - if (entry.driver?.constructor?.needsSiblingFanout) { + // Only reroute a SILENT handle, though: the 2026 Puck is a multi-receiver and + // each paired body streams on its own interface, so a streaming handle is + // already its own unit. Rerouting a live handle to rx[0] would make selecting + // the second controller silently snap back to the first (see the multi-Steam + // per-unit rollup below). + if (entry.driver?.constructor?.needsSiblingFanout && !(entry.hidActiveSince > 0)) { const rx = pool().filter((e) => forVp(e) && e.hidActiveSince > 0); if (rx.length) entry = rx[0]; } @@ -1372,17 +1377,22 @@ function overlayControllerRows() { rows.push({ key: 'active', sortId: deviceIdFor(hidDevice), name: _ctrlName(vp, hidDevice.productName), state: 'SELECTED', conn, vp, serial: serialForDevice(vp, conn), active: _padActive(readGamepad()), selected: true }); } - // Pool entries → rows. A fan-out device (Steam Controller Puck: 5 same-vid:pid - // HID interfaces on ONE physical unit) rolls up to a SINGLE row; other handles - // are one row each. Siblings of the SELECTED controller are hidden. - // TODO: multiple Steam Controllers on one Puck will need per-unit rollup (issue). + // Pool entries → rows. A fan-out device (Steam Controller Puck) exposes several + // same-vid:pid HID interfaces, but — crucially — the 2026 Puck is a MULTI- + // receiver: each paired controller body streams STATE on its OWN vendor + // interface (verified: body#1→MI_02, body#2→MI_03). So a fan-out interface is + // its own logical controller *iff it is actually streaming* (hidActiveSince>0); + // the Puck's silent siblings (keyboard/mouse collections, unpaired receiver + // slots) are NOT controllers and must not appear as phantom rows. Non-fan-out + // handles are one row each as before. (This replaces the old fan:vid:pid rollup + // that collapsed every Puck interface — and thus BOTH bodies — into one row.) const groups = new Map(); for (const entry of listManager._hidPool.values()) { const d = entry.device; if (d === hidDevice) continue; // the SELECTED device is now pooled too — shown as its own row above const isFanout = !!(entry.driver && entry.driver.constructor && entry.driver.constructor.needsSiblingFanout); - if (isFanout && hidDevice && d.vendorId === hidDevice.vendorId && d.productId === hidDevice.productId) continue; - const gk = isFanout ? ('fan:' + d.vendorId + ':' + d.productId) : ('dev:' + deviceIdFor(d)); + if (isFanout && !(entry.hidActiveSince > 0)) continue; // silent Puck sibling — not a real controller + const gk = 'dev:' + deviceIdFor(d); // per-interface row (per-unit for the multi-receiver Puck) const a = _padActive(entry.synthetic); const g = groups.get(gk); if (g) { g.active = g.active || a; } else groups.set(gk, { device: d, entry, active: a }); @@ -1405,6 +1415,22 @@ function overlayControllerRows() { // Stable order by first-sighting (deviceId) so SELECTING a controller does NOT // reorder the list — the row you clicked stays put instead of jumping to top. rows.sort((x, y) => x.sortId - y.sortId); + // Disambiguate identical display names: two Steam Controller bodies on one + // multi-receiver Puck share name + vid:pid + serial (the serial is the PUCK's, + // not the body's — see [[multi-steam-controller]]), so the rows are otherwise + // indistinguishable. Append a stable ' #n' by first-sighting order (sortId, + // already applied) so the user can tell them apart. Also covers any two + // truly-identical pads (e.g. two DS4s). Single-of-a-kind names are untouched. + const nameCounts = new Map(); + for (const r of rows) nameCounts.set(r.name, (nameCounts.get(r.name) || 0) + 1); + const nameSeen = new Map(); + for (const r of rows) { + if (nameCounts.get(r.name) > 1) { + const n = (nameSeen.get(r.name) || 0) + 1; + nameSeen.set(r.name, n); + r.name = `${r.name} #${n}`; + } + } return rows; } diff --git a/package.json b/package.json index e4cc609..acb72ba 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "split-glb": "node tools/split-glb.js", "hid-probe": "npx -y serve tools/hid-probe", "hid-dump": "node tools/hid-serial-dump.mjs", + "steam-multi-probe": "node tools/steam-multi-probe.mjs", "inventory-preview": "npx -y serve ." }, "devDependencies": { diff --git a/packages/core/src/manager.js b/packages/core/src/manager.js index bb501d1..27ab40c 100644 --- a/packages/core/src/manager.js +++ b/packages/core/src/manager.js @@ -632,8 +632,15 @@ export class ControllerManager { // getDevices() can hand back a different handle object for the same physical // device — fall back to a streaming same-vid:pid entry. if (!entry) { for (const e of this._hidPool.values()) if (sameVp(e) && e.hidActiveSince > 0) { entry = e; break; } } - // Fan-out (Steam Puck): designate the sibling actually emitting STATE reports. - if (entry && entry.driver?.constructor?.needsSiblingFanout) { + // Fan-out (Steam Puck): the designated handle may be a sibling interface + // that never emits STATE — reroute to a same-vid:pid sibling that IS + // streaming. BUT only when the designated handle isn't itself streaming: + // the 2026 Puck is a MULTI-receiver where each paired body streams on its + // OWN interface, so a streaming handle already IS its own unit. Collapsing + // to "the first streaming sibling" here would attach the same body to every + // seat (see [[multi-steam-controller]]). Keep a live handle; only rescue a + // silent one. + if (entry && entry.driver?.constructor?.needsSiblingFanout && !(entry.hidActiveSince > 0)) { for (const e of this._hidPool.values()) if (sameVp(e) && e.hidActiveSince > 0) { entry = e; break; } } if (!entry) return null; diff --git a/packages/core/test/manager-claim-hid-device-for-slot.test.js b/packages/core/test/manager-claim-hid-device-for-slot.test.js index e6b7df3..6a8d046 100644 --- a/packages/core/test/manager-claim-hid-device-for-slot.test.js +++ b/packages/core/test/manager-claim-hid-device-for-slot.test.js @@ -14,9 +14,9 @@ import assert from 'node:assert/strict'; import { ControllerManager, makeSyntheticGamepad } from '../src/manager.js'; function fakeDevice(vendorId, productId, productName) { return { vendorId, productId, productName }; } -function fakeEntry(device, { streaming = true } = {}) { +function fakeEntry(device, { streaming = true, driver = null } = {}) { return { - device, driver: null, fusion: { startCalibration() {}, ingest() {} }, + device, driver, fusion: { startCalibration() {}, ingest() {} }, synthetic: makeSyntheticGamepad(device), hasButtons: true, _everPressed: false, hidActiveSince: streaming ? 1 : 0, slot: null, @@ -24,6 +24,11 @@ function fakeEntry(device, { streaming = true } = {}) { }; } +// A driver whose class advertises sibling-fanout, like SteamControllerDriver. +class FanoutDriver {} +FanoutDriver.needsSiblingFanout = true; +function fanoutEntry(device, opts = {}) { return fakeEntry(device, { ...opts, driver: new FanoutDriver() }); } + test('claimHidDeviceForSlot claims a pooled HID device into a specific empty slot', () => { const m = new ControllerManager({ slotIds: ['P1', 'P2', 'P3'] }); const dev = fakeDevice(0x28de, 0x1304, 'Steam Controller Puck'); @@ -48,3 +53,36 @@ test('claimHidDeviceForSlot rejects missing device / unpooled device / occupied assert.equal(m.claimHidDeviceForSlot('P1', switchpro), null, 'occupied slot rejected'); assert.equal(m.claimHidDeviceForSlot('nope', switchpro), null, 'unknown slot rejected'); }); + +test('claimHidDeviceForSlot keeps a streaming fan-out handle (two Steam bodies on one Puck → distinct seats)', () => { + // The 2026 Steam Controller Puck is a multi-receiver: two paired bodies each + // stream STATE on their OWN same-vid:pid vendor interface. Both handles are + // streaming, so each must seat as ITSELF — not collapse onto "the first + // streaming sibling", which would attach one body to both seats. + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const bodyA = fakeDevice(0x28de, 0x1304, 'Steam Controller Puck'); // MI_02 + const bodyB = fakeDevice(0x28de, 0x1304, 'Steam Controller Puck'); // MI_03 + m._hidPool.set(bodyA, fanoutEntry(bodyA, { streaming: true })); + m._hidPool.set(bodyB, fanoutEntry(bodyB, { streaming: true })); + + const s1 = m.claimHidDeviceForSlot('P1', bodyA); + const s2 = m.claimHidDeviceForSlot('P2', bodyB); + assert.ok(s1 && s2, 'both seats claimed'); + assert.equal(m.getSlot('P1')._hidEntry.device, bodyA, 'P1 bound to body A (MI_02)'); + assert.equal(m.getSlot('P2')._hidEntry.device, bodyB, 'P2 bound to body B (MI_03) — not a duplicate of A'); + assert.notEqual(m.getSlot('P1')._hidEntry.device, m.getSlot('P2')._hidEntry.device, 'distinct physical units'); +}); + +test('claimHidDeviceForSlot reroutes a SILENT fan-out handle to a streaming sibling (single-body Puck)', () => { + // Single controller behind the Puck: the granted handle may be an interface + // that never emits STATE. Then (and only then) we reroute to the streaming + // same-vid:pid sibling. + const m = new ControllerManager({ slotIds: ['P1'] }); + const silent = fakeDevice(0x28de, 0x1304, 'Steam Controller Puck'); // e.g. MI_04, no STATE + const streaming = fakeDevice(0x28de, 0x1304, 'Steam Controller Puck'); // MI_03, streaming + m._hidPool.set(silent, fanoutEntry(silent, { streaming: false })); + m._hidPool.set(streaming, fanoutEntry(streaming, { streaming: true })); + const slot = m.claimHidDeviceForSlot('P1', silent); + assert.ok(slot, 'claimed'); + assert.equal(m.getSlot('P1')._hidEntry.device, streaming, 'rerouted silent handle to the streaming sibling'); +}); diff --git a/tools/steam-multi-probe.mjs b/tools/steam-multi-probe.mjs new file mode 100644 index 0000000..86e3234 --- /dev/null +++ b/tools/steam-multi-probe.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// ============================================================ +// steam-multi-probe.mjs — map multiple Steam Controllers to Puck interfaces +// ============================================================ +// +// The 2026 Steam Controller Puck (0x28de:0x1304) exposes several +// vendor-defined HID interfaces (usagePage 0xff00). node-hid reports the +// SAME serialNumber for every one of them (it's the Puck's serial, not the +// controller body's), so two controller bodies paired to one Puck can NOT be +// told apart by serial. The open question this tool answers on real hardware: +// +// Does each paired controller body stream STATE reports on its OWN Puck +// vendor interface (MI_02 / MI_03 / MI_04 / MI_05 …), so we can key per-unit +// identity off the interface path? Or do all bodies multiplex through one +// interface with a controller-index byte in the report? +// +// It opens every 0xff00 vendor interface, sends the lizard-mode disable +// (CLEAR_DIGITAL_MAPPINGS + SET_SETTINGS) + an 800ms heartbeat so STATE +// reports actually flow, and prints a live per-interface table: report count, +// last STATE report id, and whether input is currently changing. Press +// buttons / move sticks on ONE controller at a time and watch which interface +// lights up. +// +// node tools/steam-multi-probe.mjs +// +// Ctrl-C to stop. + +import HID from 'node-hid'; + +const VALVE = 0x28de; +const PUCK_PID = 0x1304; + +// Lizard-mode disable protocol (mirrors packages/core/src/drivers/steam-controller-driver.js) +const CMD_CLEAR_DIGITAL_MAPPINGS = 0x81; +const CMD_SET_SETTINGS = 0x87; +const SETTING_RIGHT_TRACKPAD_MODE = 0x07; +const SETTING_LEFT_TRACKPAD_MODE = 0x08; +const TRACKPAD_MODE_NONE = 0x00; +const HEARTBEAT_MS = 800; + +// STATE report gating (from the driver): 53 payload bytes, id 0x45 (or 0x42). +const STATE_REPORT_IDS = new Set([0x45, 0x42]); + +const hx = (n, w = 2) => '0x' + ((n || 0) >>> 0).toString(16).padStart(w, '0'); + +function clearPayload() { + const buf = Buffer.alloc(64); + buf[0] = CMD_CLEAR_DIGITAL_MAPPINGS; + return buf; +} +function setSettingsPayload() { + const buf = Buffer.alloc(64); + buf[0] = CMD_SET_SETTINGS; + buf[1] = 6; + buf[2] = SETTING_LEFT_TRACKPAD_MODE; buf[3] = TRACKPAD_MODE_NONE; buf[4] = 0x00; + buf[5] = SETTING_RIGHT_TRACKPAD_MODE; buf[6] = TRACKPAD_MODE_NONE; buf[7] = 0x00; + return buf; +} + +// node-hid's sendFeatureReport takes a buffer whose [0] byte is the report id. +// The Steam Controller's vendor feature reports use report id 0. We try 0 +// first, then 1/2 as the WebHID driver does. +function sendLizardDisable(dev, label) { + for (const reportId of [0x00, 0x01, 0x02]) { + try { + dev.sendFeatureReport(Buffer.concat([Buffer.from([reportId]), clearPayload()])); + dev.sendFeatureReport(Buffer.concat([Buffer.from([reportId]), setSettingsPayload()])); + return reportId; + } catch { /* try next id */ } + } + return null; +} + +const vendorIfaces = HID.devices().filter( + (d) => d.vendorId === VALVE && d.productId === PUCK_PID && d.usagePage === 0xff00 && d.usage === 0x0001, +); + +if (vendorIfaces.length === 0) { + console.error('No Steam Controller Puck vendor interfaces (0xff00/0x0001) found. Is the Puck plugged in?'); + process.exit(1); +} + +console.log(`Found ${vendorIfaces.length} Puck vendor interface(s). Opening + disabling lizard mode…\n`); + +const monitors = []; +for (const info of vendorIfaces) { + const mi = (info.path.match(/MI_([0-9A-Fa-f]{2})/) || [])[1] ?? '??'; + const label = `MI_${mi}`; + let dev; + try { + dev = new HID.HID(info.path); + } catch (err) { + console.log(` ${label}: open FAILED — ${err.message}`); + continue; + } + const state = { + label, dev, + reports: 0, stateReports: 0, lastId: null, + lastPayload: null, changed: false, changeCount: 0, + acceptedFeatureId: null, + }; + dev.on('data', (data) => { + state.reports++; + // node-hid prepends the report id as byte 0 when the device uses numbered + // reports. So node-hid index = WebHID offset + 1. The driver reads btn0 at + // WebHID offset 1 → node-hid index 2; sticks at WebHID 9..16 → 10..17. + // Bytes 5..8 (WebHID) are the triggers, 29..32 a fast-incrementing + // timestamp — we deliberately EXCLUDE the timestamp so "ACTIVE" reflects + // real user input, not the free-running clock. + const reportId = data[0]; + if (STATE_REPORT_IDS.has(reportId)) { + state.stateReports++; + state.lastId = reportId; + // ACTIVE = a digital control changed. Buttons btn0..btn2 + grip flags = + // node-hid indices 2,3,4,5. Sticks are analog-noisy at rest, so we key + // ACTIVE off buttons only — press any face/shoulder/dpad button to light + // an interface. (Stick/trackpad mapping can be checked via btn=[] plus a + // later analog view if needed.) + const watch = [2, 3, 4, 5]; + if (state.lastPayload) { + let diff = false; + for (const i of watch) { + if (data[i] !== state.lastPayload[i]) { diff = true; break; } + } + if (diff) { state.changed = true; state.changeCount++; } + } + state.btnHex = [2, 3, 4].map((i) => (data[i] ?? 0).toString(16).padStart(2, '0')).join(' '); + state.lastPayload = Buffer.from(data); + } + }); + dev.on('error', () => {}); + state.acceptedFeatureId = sendLizardDisable(dev, label); + monitors.push(state); + console.log(` ${label}: opened (feature id ${state.acceptedFeatureId != null ? hx(state.acceptedFeatureId) : 'none accepted'}) path=…${info.path.slice(-32)}`); +} + +if (monitors.length === 0) { console.error('\nCould not open any vendor interface.'); process.exit(1); } + +console.log('\nHeartbeating lizard-disable every 800ms. Press buttons / move sticks on ONE controller at a time.'); +console.log('Watch which MI_xx interface shows STATE reports + ACTIVE.\n'); + +const heartbeat = setInterval(() => { + for (const m of monitors) { + try { m.dev.sendFeatureReport(Buffer.concat([Buffer.from([m.acceptedFeatureId ?? 0]), clearPayload()])); } catch {} + } +}, HEARTBEAT_MS); + +const report = setInterval(() => { + const lines = monitors.map((m) => { + const active = m.changed ? 'ACTIVE' : ' - '; + m.changed = false; + return ` ${m.label} state=${String(m.stateReports).padStart(6)} btn=[${m.btnHex || '-- -- --'}] ${active} changes=${m.changeCount}`; + }); + console.log(`[${new Date().toISOString().slice(11, 19)}]`); + console.log(lines.join('\n')); +}, 1000); + +function shutdown() { + clearInterval(heartbeat); + clearInterval(report); + console.log('\n\n=== SUMMARY ==='); + for (const m of monitors) { + console.log(` ${m.label}: ${m.stateReports} STATE reports, ${m.changeCount} input-change ticks, lastId=${m.lastId != null ? hx(m.lastId) : 'none'}`); + try { m.dev.close(); } catch {} + } + const streaming = monitors.filter((m) => m.stateReports > 0); + console.log(`\n${streaming.length} interface(s) streamed STATE: ${streaming.map((m) => m.label).join(', ') || 'none'}`); + console.log('If each controller mapped to a DISTINCT interface, per-unit identity = interface path.'); + process.exit(0); +} + +process.on('SIGINT', shutdown); From 1a6ae5ffded948f52d675206d33cec09de1f5a42 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Wed, 8 Jul 2026 16:55:43 -0400 Subject: [PATCH 02/10] Wire multiple Steam Controllers into start:multi (WebHID-first boot pooling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the WebHID-first migration for the multi/lobby apps: the single overlay pools every known approved HID device directly, but multi-app and lobby-app still pool via autoPoolApprovedHid(), whose "must be live in the Gamepad API" stale-pairing gate structurally excludes HID-only controllers. A Steam Controller Puck is vendor-defined HID and NEVER enumerated by the Gamepad API, so in a mixed setup (Steam + an Xbox) the present Puck was wrongly skipped at boot. - devices.js: mark both Steam Controller entries `hidOnly` (input only via WebHID, never in the Gamepad API). - manager.autoPoolApprovedHid: `hidOnly` entries bypass the Gamepad-API liveness gate — they can't satisfy it by definition. Stale/absent handles are still cleaned up by the existing phantom-eviction sweep in ingestFrame. - Tests: manager-multi-steam-claim (two HID-only bodies seat into distinct slots via ingestFrame — same frame and sequential; a single body claims exactly one seat) and manager-autopool-hidonly (gate bypass for hidOnly, unchanged skip for stale non-hidOnly). 129 core tests pass. The pseudo-pad id the manager builds for a HID claim resolves to the steam-controller profile, so the multi view swaps to the Steam model on claim. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW --- packages/core/src/devices.js | 8 ++ packages/core/src/manager.js | 10 +- .../test/manager-autopool-hidonly.test.js | 83 ++++++++++++++ .../test/manager-multi-steam-claim.test.js | 108 ++++++++++++++++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/manager-autopool-hidonly.test.js create mode 100644 packages/core/test/manager-multi-steam-claim.test.js diff --git a/packages/core/src/devices.js b/packages/core/src/devices.js index 08c61a3..c5ff2fd 100644 --- a/packages/core/src/devices.js +++ b/packages/core/src/devices.js @@ -48,6 +48,12 @@ // entry is a clone advertising another // device's USB identity. `of` is the // human-readable name of the spoofed device. +// hidOnly?: boolean — true when input comes ONLY over WebHID and +// the device is NEVER enumerated by the Gamepad +// API (vendor-defined HID, e.g. the Steam +// Controller Puck). Lets boot-pooling skip the +// "must be live in the Gamepad API" stale-pairing +// guard for a device that can't be, by definition. // controllerProfile?: string — visualizer profile key (a key in the // visualizer's PROFILES map). Defaults to // `protocol` when missing — so Sony DS4/DS5 @@ -257,6 +263,7 @@ export const DEVICES = [ name: 'Steam Controller 2026 (direct USB-C)', vendorId: 0x28de, productId: 0x1302, protocol: 'steam-controller', + hidOnly: true, capabilities: PS_CAPS, features: { faceButtons: true, systemButtons: true, triggers: 'analog', shoulders: true, sticks: 2, dpad: true, gyro: true, accel: true, touchpad: true, backPaddles: true, gripSense: true, lightbar: false, rumble: true }, trackpadCount: 2, haptics: HAPTICS.steam, @@ -268,6 +275,7 @@ export const DEVICES = [ name: 'Steam Controller 2026 (via Puck)', vendorId: 0x28de, productId: 0x1304, protocol: 'steam-controller', + hidOnly: true, capabilities: PS_CAPS, features: { faceButtons: true, systemButtons: true, triggers: 'analog', shoulders: true, sticks: 2, dpad: true, gyro: true, accel: true, touchpad: true, backPaddles: true, gripSense: true, lightbar: false, rumble: true }, trackpadCount: 2, haptics: HAPTICS.steam, diff --git a/packages/core/src/manager.js b/packages/core/src/manager.js index 27ab40c..212b877 100644 --- a/packages/core/src/manager.js +++ b/packages/core/src/manager.js @@ -1224,7 +1224,15 @@ export class ControllerManager { for (const d of known) { if (this._isDeviceInPoolOrSlot(d)) continue; const key = `${d.vendorId}:${d.productId}`; - if (liveVidPids.size > 0 && !liveVidPids.has(key)) { + // HID-only controllers (Steam Controller Puck — vendor-defined HID, + // never enumerated by the Gamepad API) can't be "live in the Gamepad + // API" by definition, so the stale-pairing skip would wrongly drop a + // PRESENT one whenever some other Gamepad-API pad (an Xbox, say) is + // also connected — breaking Steam multiplayer in a mixed setup. Pool + // them regardless; the phantom-eviction sweep removes any that never + // stream. See [[multi-steam-controller]]. + const hidOnly = !!ControllerRegistry.getEntry(d.vendorId, d.productId)?.hidOnly; + if (!hidOnly && liveVidPids.size > 0 && !liveVidPids.has(key)) { console.log(`[manager] skipping stale HID pairing ${key} (not live in Gamepad API)`); continue; } diff --git a/packages/core/test/manager-autopool-hidonly.test.js b/packages/core/test/manager-autopool-hidonly.test.js new file mode 100644 index 0000000..3f84f44 --- /dev/null +++ b/packages/core/test/manager-autopool-hidonly.test.js @@ -0,0 +1,83 @@ +// ============================================================ +// ControllerManager.autoPoolApprovedHid — HID-only devices bypass the +// "must be live in the Gamepad API" stale-pairing gate +// ============================================================ +// +// The boot-pooling gate skips approved HID devices whose vid:pid has no live +// Gamepad-API pad, so a stale pairing from a past session isn't opened. But a +// Steam Controller Puck is vendor-defined HID and NEVER appears in the Gamepad +// API — so when it's present alongside some other Gamepad-API pad (an Xbox), +// the gate would wrongly skip the present Puck and Steam multiplayer would find +// no controllers. Entries flagged `hidOnly` bypass the gate. See +// [[multi-steam-controller]]. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ControllerManager } from '../src/manager.js'; + +// Steam Controller Puck (hidOnly in the dictionary) + a normal DualSense. +const PUCK = { vendorId: 0x28de, productId: 0x1304, productName: 'Steam Controller Puck' }; +const DS5 = { vendorId: 0x054c, productId: 0x0ce6, productName: 'DualSense Wireless Controller' }; +const UNKNOWN = { vendorId: 0x1234, productId: 0x5678, productName: 'Mystery Pad' }; + +function withNavigator({ approvedHid, gamepads }, fn) { + const orig = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + hid: { getDevices: async () => approvedHid }, + getGamepads: () => gamepads, + }, + }); + return Promise.resolve(fn()).finally(() => { + if (orig) Object.defineProperty(globalThis, 'navigator', orig); + else delete globalThis.navigator; + }); +} + +// A gamepad-shaped object whose id carries a vid:pid (Xbox here). +function xboxPad(index) { + return { index, id: 'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)', mapping: 'standard', buttons: [], axes: [] }; +} + +// Spy on poolDevice so we test the GATE decision, not the heavy pooling +// machinery (real driver init + the `three` import, which core tests avoid). +function spyPool(m) { + const pooled = []; + m.poolDevice = async (d) => { pooled.push(d); return { device: d }; }; + return pooled; +} + +test('autoPoolApprovedHid pools a present Steam Puck even when another pad is the only live Gamepad-API device', async () => { + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const pooled = spyPool(m); + // Only the Xbox is "live" in the Gamepad API; the Puck is HID-only and never + // shows there. The Puck must still pool. + await withNavigator({ approvedHid: [PUCK], gamepads: [xboxPad(0)] }, () => m.autoPoolApprovedHid()); + assert.ok(pooled.includes(PUCK), 'hidOnly Puck pooled despite no Gamepad-API liveness'); +}); + +test('autoPoolApprovedHid still SKIPS a non-hidOnly device with no live Gamepad-API counterpart', async () => { + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const pooled = spyPool(m); + // A DualSense that is approved-but-absent (only the Xbox is live) is a stale + // pairing — the gate should still skip it (unchanged behavior). + await withNavigator({ approvedHid: [DS5], gamepads: [xboxPad(0)] }, () => m.autoPoolApprovedHid()); + assert.ok(!pooled.includes(DS5), 'stale non-hidOnly pairing still skipped'); +}); + +test('autoPoolApprovedHid pools a non-hidOnly device when its OWN Gamepad-API pad is live', async () => { + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const pooled = spyPool(m); + const ds5Pad = { index: 0, id: 'DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)', mapping: 'standard', buttons: [], axes: [] }; + await withNavigator({ approvedHid: [DS5], gamepads: [ds5Pad] }, () => m.autoPoolApprovedHid()); + assert.ok(pooled.includes(DS5), 'DualSense pooled when its own pad is live (unchanged)'); +}); + +test('autoPoolApprovedHid ignores unknown devices entirely', async () => { + const m = new ControllerManager({ slotIds: ['P1'] }); + const pooled = spyPool(m); + await withNavigator({ approvedHid: [UNKNOWN], gamepads: [] }, () => m.autoPoolApprovedHid()); + assert.ok(!pooled.includes(UNKNOWN), 'unknown device not pooled'); +}); diff --git a/packages/core/test/manager-multi-steam-claim.test.js b/packages/core/test/manager-multi-steam-claim.test.js new file mode 100644 index 0000000..48b4931 --- /dev/null +++ b/packages/core/test/manager-multi-steam-claim.test.js @@ -0,0 +1,108 @@ +// ============================================================ +// ControllerManager — two Steam Controllers on one Puck seat distinctly +// ============================================================ +// +// The 2026 Steam Controller Puck is a multi-receiver: two paired bodies each +// stream STATE on their OWN same-vid:pid vendor interface, and NEITHER appears +// in the Gamepad API (the Puck is vendor-defined HID). So both seat purely via +// ingestFrame's WebHID-activity claim path (hasFreshInput), with no Gamepad pad +// to correlate against. Because the manager pools each interface as its own +// entry with its own synthetic report stream (reports are NOT fanned across +// siblings), a press on body #1 lights only its interface — so P1 binds body #1 +// and P2 binds body #2, each to its own gyro/buttons. This is what start:multi +// relies on. See [[multi-steam-controller]]. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ControllerManager, makeSyntheticGamepad } from '../src/manager.js'; + +// Two distinct physical bodies behind one Puck: identical vid:pid + productName, +// no serial exposed to the browser. Distinguished only by report stream. +function puckDevice() { return { vendorId: 0x28de, productId: 0x1304, productName: 'Steam Controller Puck' }; } + +// A faithful-enough HID-only entry: fresh input is driven by whatever button is +// currently pressed on THIS entry's own synthetic (its own interface's stream). +function steamEntry(device) { + const entry = { + device, + driver: null, + fusion: { startCalibration() {}, ingest() {} }, + synthetic: makeSyntheticGamepad(device), + hasButtons: true, + _everPressed: false, + hidActiveSince: 1, // streaming STATE since boot (~249 Hz) + slot: null, + _recentBtns: new Map(), + destroy() {}, + }; + const anyPressed = () => entry.synthetic.buttons.some((b) => b && (b.pressed || (b.value || 0) > 0.5)); + entry.hasFreshButtonPress = anyPressed; + entry.hasFreshInput = anyPressed; + return entry; +} + +// Press a button on this body's own interface stream (and mark it as having +// pressed, like a real report would via _everPressed). +function press(entry, i) { entry.synthetic.buttons[i].pressed = true; entry.synthetic.buttons[i].value = 1; entry._everPressed = true; } +function release(entry, i) { entry.synthetic.buttons[i].pressed = false; entry.synthetic.buttons[i].value = 0; } + +test('two Steam bodies, sequential presses → P1 binds body A, P2 binds body B', () => { + const m = new ControllerManager({ slotIds: ['P1', 'P2', 'P3', 'P4'] }); + const devA = puckDevice(), devB = puckDevice(); + const entryA = steamEntry(devA), entryB = steamEntry(devB); + m._hidPool.set(devA, entryA); + m._hidPool.set(devB, entryB); + + // Body A presses to join. No Gamepad pads exist for a Steam Controller. + press(entryA, 0); // cross + m.ingestFrame([], 1000); + release(entryA, 0); + + assert.equal(m.getSlot('P1').state, 'claimed', 'P1 claimed by the first body to press'); + assert.equal(m.getSlot('P1')._hidEntry, entryA, 'P1 bound to body A'); + assert.equal(m.getSlot('P2').state, 'empty', 'body B has not pressed yet'); + assert.equal(m._hidPool.has(devA), false, 'body A left the pool'); + + // Body B presses to join a second seat. + press(entryB, 1); // circle + m.ingestFrame([], 1200); + release(entryB, 1); + + assert.equal(m.getSlot('P2').state, 'claimed', 'P2 claimed by the second body'); + assert.equal(m.getSlot('P2')._hidEntry, entryB, 'P2 bound to body B — its OWN handle, not A again'); + assert.notEqual(m.getSlot('P1')._hidEntry, m.getSlot('P2')._hidEntry, 'distinct physical units per seat'); + assert.equal(m._hidPool.size, 0, 'both bodies bound'); +}); + +test('two Steam bodies pressing in the SAME frame still seat distinctly (no cross-wire)', () => { + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const devA = puckDevice(), devB = puckDevice(); + const entryA = steamEntry(devA), entryB = steamEntry(devB); + m._hidPool.set(devA, entryA); + m._hidPool.set(devB, entryB); + + press(entryA, 0); + press(entryB, 3); + m.ingestFrame([], 1000); + + assert.equal(m.getSlot('P1')._hidEntry, entryA, 'P1 → body A'); + assert.equal(m.getSlot('P2')._hidEntry, entryB, 'P2 → body B'); + assert.equal(m._hidPool.size, 0, 'both bound, one seat each'); +}); + +test('a single Steam body claims exactly ONE seat (not one per streaming interface)', () => { + // Guards the failure mode where every streaming interface of one body would + // grab its own seat. Here there is one body = one streaming interface, so + // exactly one seat should fill. + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const dev = puckDevice(); + const entry = steamEntry(dev); + m._hidPool.set(dev, entry); + + press(entry, 0); + m.ingestFrame([], 1000); + + assert.equal(m.getSlot('P1')._hidEntry, entry, 'P1 bound to the one body'); + assert.equal(m.getSlot('P2').state, 'empty', 'no phantom second seat'); +}); From 89b64f7fa03554e8cc46ef244ebbabb25872a68d Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Wed, 8 Jul 2026 17:15:34 -0400 Subject: [PATCH 03/10] WebHID-first: remove the Gamepad-API gate from autoPoolApprovedHid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the intended architecture — WebHID is the primary input path, Gamepad API / XInput is only a fallback (Xbox) — boot-pooling should pool every approved, known HID device. The "must be live in the Gamepad API" gate did the opposite: it structurally dropped HID-only controllers (the Steam Controller Puck is vendor-defined HID and never appears in the Gamepad API) whenever any other Gamepad-API pad was connected. The single overlay already pooled gate-free (inline loop); multi-app and lobby-app still called the gated autoPoolApprovedHid — the migration hadn't reached them. This finishes it: - manager.autoPoolApprovedHid: gate removed; pools all approved known HID devices. Stale approved-but-absent pairings are still cleaned by the phantom-eviction sweep in ingestFrame (unchanged). - app.js: single overlay's inline pool loop converged onto the shared autoPoolApprovedHid so all three apps boot HID identically (no future drift). - devices.js: drop the interim `hidOnly` flag (only existed to bypass the now-gone gate). - tests: replace manager-autopool-hidonly with manager-autopool-webhid-first (pools regardless of Gamepad-API liveness; ignores unknown; no re-pool). 130 core tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW --- apps/overlay/src/js/app.js | 9 +- packages/core/src/devices.js | 8 -- packages/core/src/manager.js | 43 ++++----- .../test/manager-autopool-hidonly.test.js | 83 ------------------ .../manager-autopool-webhid-first.test.js | 87 +++++++++++++++++++ 5 files changed, 110 insertions(+), 120 deletions(-) delete mode 100644 packages/core/test/manager-autopool-hidonly.test.js create mode 100644 packages/core/test/manager-autopool-webhid-first.test.js diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 0f489de..1480de7 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -169,10 +169,11 @@ const listManager = new ControllerManager({ slotIds: ['_ovl'] }); async function initControllerList() { if (!navigator.hid) return; try { - const approved = await navigator.hid.getDevices(); - for (const d of approved) { - if (ControllerRegistry.isKnownDevice(d)) await listManager.poolDevice(d); - } + // WebHID-first: pool every approved, known HID device (no Gamepad-API gate). + // Shared with the multi/lobby apps so all three boot HID the same way — this + // used to be an inline loop here that drifted from the gated version those + // apps called. See ControllerManager.autoPoolApprovedHid. + await listManager.autoPoolApprovedHid(); listManager.wireHidHotplug(); } catch (e) { console.warn('[overlay] controller-pool init failed', e); } } diff --git a/packages/core/src/devices.js b/packages/core/src/devices.js index c5ff2fd..08c61a3 100644 --- a/packages/core/src/devices.js +++ b/packages/core/src/devices.js @@ -48,12 +48,6 @@ // entry is a clone advertising another // device's USB identity. `of` is the // human-readable name of the spoofed device. -// hidOnly?: boolean — true when input comes ONLY over WebHID and -// the device is NEVER enumerated by the Gamepad -// API (vendor-defined HID, e.g. the Steam -// Controller Puck). Lets boot-pooling skip the -// "must be live in the Gamepad API" stale-pairing -// guard for a device that can't be, by definition. // controllerProfile?: string — visualizer profile key (a key in the // visualizer's PROFILES map). Defaults to // `protocol` when missing — so Sony DS4/DS5 @@ -263,7 +257,6 @@ export const DEVICES = [ name: 'Steam Controller 2026 (direct USB-C)', vendorId: 0x28de, productId: 0x1302, protocol: 'steam-controller', - hidOnly: true, capabilities: PS_CAPS, features: { faceButtons: true, systemButtons: true, triggers: 'analog', shoulders: true, sticks: 2, dpad: true, gyro: true, accel: true, touchpad: true, backPaddles: true, gripSense: true, lightbar: false, rumble: true }, trackpadCount: 2, haptics: HAPTICS.steam, @@ -275,7 +268,6 @@ export const DEVICES = [ name: 'Steam Controller 2026 (via Puck)', vendorId: 0x28de, productId: 0x1304, protocol: 'steam-controller', - hidOnly: true, capabilities: PS_CAPS, features: { faceButtons: true, systemButtons: true, triggers: 'analog', shoulders: true, sticks: 2, dpad: true, gyro: true, accel: true, touchpad: true, backPaddles: true, gripSense: true, lightbar: false, rumble: true }, trackpadCount: 2, haptics: HAPTICS.steam, diff --git a/packages/core/src/manager.js b/packages/core/src/manager.js index 212b877..9674de0 100644 --- a/packages/core/src/manager.js +++ b/packages/core/src/manager.js @@ -1205,37 +1205,30 @@ export class ControllerManager { } /** - * Pool approved HID devices at boot (no user gesture required). - * Only pools devices whose vid:pid has a live Gamepad API counterpart, - * so stale pairings from prior sessions don't get initialized. + * Pool every approved, known HID device at boot (no user gesture required). + * + * WebHID-first: WebHID is the primary input path, so we pool any controller + * with a WebHID driver regardless of whether the Gamepad API also sees it. + * The Gamepad API / XInput is the FALLBACK — ingestFrame's Gamepad-claim loop + * covers controllers that provide input only there (Xbox), and a WebHID-pooled + * pad that ALSO enumerates in the Gamepad API is de-duped at claim time. + * + * There is deliberately NO "is it live in the Gamepad API?" gate here: it + * structurally excluded HID-only controllers (the Steam Controller Puck is + * vendor-defined HID and never appears in the Gamepad API), so a present Puck + * was dropped whenever any other Gamepad-API pad was connected. Stale pairings + * from prior sessions (approved but not physically present) are handled the + * same way the single overlay handles them — the phantom-eviction sweep in + * ingestFrame drops any pooled handle that never streams a raw report within + * the probation window. See [[multi-steam-controller]]. */ async autoPoolApprovedHid() { if (!navigator.hid) return; try { const approved = await navigator.hid.getDevices(); - const known = approved.filter((d) => ControllerRegistry.isKnownDevice(d)); - const liveVidPids = new Set(); - const pads = (navigator.getGamepads && navigator.getGamepads()) || []; - for (const gp of pads) { - if (!gp) continue; - const vp = ControllerRegistry.parseGamepadVendorProduct(gp.id); - if (vp) liveVidPids.add(`${vp.vendorId}:${vp.productId}`); - } - for (const d of known) { + for (const d of approved) { + if (!ControllerRegistry.isKnownDevice(d)) continue; if (this._isDeviceInPoolOrSlot(d)) continue; - const key = `${d.vendorId}:${d.productId}`; - // HID-only controllers (Steam Controller Puck — vendor-defined HID, - // never enumerated by the Gamepad API) can't be "live in the Gamepad - // API" by definition, so the stale-pairing skip would wrongly drop a - // PRESENT one whenever some other Gamepad-API pad (an Xbox, say) is - // also connected — breaking Steam multiplayer in a mixed setup. Pool - // them regardless; the phantom-eviction sweep removes any that never - // stream. See [[multi-steam-controller]]. - const hidOnly = !!ControllerRegistry.getEntry(d.vendorId, d.productId)?.hidOnly; - if (!hidOnly && liveVidPids.size > 0 && !liveVidPids.has(key)) { - console.log(`[manager] skipping stale HID pairing ${key} (not live in Gamepad API)`); - continue; - } await this.poolDevice(d); } } catch (err) { diff --git a/packages/core/test/manager-autopool-hidonly.test.js b/packages/core/test/manager-autopool-hidonly.test.js deleted file mode 100644 index 3f84f44..0000000 --- a/packages/core/test/manager-autopool-hidonly.test.js +++ /dev/null @@ -1,83 +0,0 @@ -// ============================================================ -// ControllerManager.autoPoolApprovedHid — HID-only devices bypass the -// "must be live in the Gamepad API" stale-pairing gate -// ============================================================ -// -// The boot-pooling gate skips approved HID devices whose vid:pid has no live -// Gamepad-API pad, so a stale pairing from a past session isn't opened. But a -// Steam Controller Puck is vendor-defined HID and NEVER appears in the Gamepad -// API — so when it's present alongside some other Gamepad-API pad (an Xbox), -// the gate would wrongly skip the present Puck and Steam multiplayer would find -// no controllers. Entries flagged `hidOnly` bypass the gate. See -// [[multi-steam-controller]]. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; - -import { ControllerManager } from '../src/manager.js'; - -// Steam Controller Puck (hidOnly in the dictionary) + a normal DualSense. -const PUCK = { vendorId: 0x28de, productId: 0x1304, productName: 'Steam Controller Puck' }; -const DS5 = { vendorId: 0x054c, productId: 0x0ce6, productName: 'DualSense Wireless Controller' }; -const UNKNOWN = { vendorId: 0x1234, productId: 0x5678, productName: 'Mystery Pad' }; - -function withNavigator({ approvedHid, gamepads }, fn) { - const orig = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: { - hid: { getDevices: async () => approvedHid }, - getGamepads: () => gamepads, - }, - }); - return Promise.resolve(fn()).finally(() => { - if (orig) Object.defineProperty(globalThis, 'navigator', orig); - else delete globalThis.navigator; - }); -} - -// A gamepad-shaped object whose id carries a vid:pid (Xbox here). -function xboxPad(index) { - return { index, id: 'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)', mapping: 'standard', buttons: [], axes: [] }; -} - -// Spy on poolDevice so we test the GATE decision, not the heavy pooling -// machinery (real driver init + the `three` import, which core tests avoid). -function spyPool(m) { - const pooled = []; - m.poolDevice = async (d) => { pooled.push(d); return { device: d }; }; - return pooled; -} - -test('autoPoolApprovedHid pools a present Steam Puck even when another pad is the only live Gamepad-API device', async () => { - const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); - const pooled = spyPool(m); - // Only the Xbox is "live" in the Gamepad API; the Puck is HID-only and never - // shows there. The Puck must still pool. - await withNavigator({ approvedHid: [PUCK], gamepads: [xboxPad(0)] }, () => m.autoPoolApprovedHid()); - assert.ok(pooled.includes(PUCK), 'hidOnly Puck pooled despite no Gamepad-API liveness'); -}); - -test('autoPoolApprovedHid still SKIPS a non-hidOnly device with no live Gamepad-API counterpart', async () => { - const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); - const pooled = spyPool(m); - // A DualSense that is approved-but-absent (only the Xbox is live) is a stale - // pairing — the gate should still skip it (unchanged behavior). - await withNavigator({ approvedHid: [DS5], gamepads: [xboxPad(0)] }, () => m.autoPoolApprovedHid()); - assert.ok(!pooled.includes(DS5), 'stale non-hidOnly pairing still skipped'); -}); - -test('autoPoolApprovedHid pools a non-hidOnly device when its OWN Gamepad-API pad is live', async () => { - const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); - const pooled = spyPool(m); - const ds5Pad = { index: 0, id: 'DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)', mapping: 'standard', buttons: [], axes: [] }; - await withNavigator({ approvedHid: [DS5], gamepads: [ds5Pad] }, () => m.autoPoolApprovedHid()); - assert.ok(pooled.includes(DS5), 'DualSense pooled when its own pad is live (unchanged)'); -}); - -test('autoPoolApprovedHid ignores unknown devices entirely', async () => { - const m = new ControllerManager({ slotIds: ['P1'] }); - const pooled = spyPool(m); - await withNavigator({ approvedHid: [UNKNOWN], gamepads: [] }, () => m.autoPoolApprovedHid()); - assert.ok(!pooled.includes(UNKNOWN), 'unknown device not pooled'); -}); diff --git a/packages/core/test/manager-autopool-webhid-first.test.js b/packages/core/test/manager-autopool-webhid-first.test.js new file mode 100644 index 0000000..012396d --- /dev/null +++ b/packages/core/test/manager-autopool-webhid-first.test.js @@ -0,0 +1,87 @@ +// ============================================================ +// ControllerManager.autoPoolApprovedHid — WebHID-first boot pooling +// ============================================================ +// +// WebHID is the primary input path: boot-pooling pools EVERY approved, known +// HID device, with NO "is it live in the Gamepad API?" gate. That gate used to +// drop HID-only controllers (the Steam Controller Puck is vendor-defined HID +// and never appears in the Gamepad API) whenever any other Gamepad-API pad was +// connected. The Gamepad API / XInput is now purely a FALLBACK (ingestFrame's +// Gamepad-claim loop). Stale approved-but-absent pairings are cleaned by the +// phantom-eviction sweep in ingestFrame, not by refusing to pool them. See +// [[multi-steam-controller]]. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ControllerManager } from '../src/manager.js'; + +const PUCK = { vendorId: 0x28de, productId: 0x1304, productName: 'Steam Controller Puck' }; +const DS5 = { vendorId: 0x054c, productId: 0x0ce6, productName: 'DualSense Wireless Controller' }; +const UNKNOWN = { vendorId: 0x1234, productId: 0x5678, productName: 'Mystery Pad' }; + +function withNavigator({ approvedHid, gamepads = [] }, fn) { + const orig = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + hid: { getDevices: async () => approvedHid }, + getGamepads: () => gamepads, + }, + }); + return Promise.resolve(fn()).finally(() => { + if (orig) Object.defineProperty(globalThis, 'navigator', orig); + else delete globalThis.navigator; + }); +} + +function xboxPad(index) { + return { index, id: 'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)', mapping: 'standard', buttons: [], axes: [] }; +} + +// Spy on poolDevice so we exercise the pooling DECISION, not the heavy driver +// init + `three` import that core's dependency-free tests avoid. +function spyPool(m) { + const pooled = []; + m.poolDevice = async (d) => { pooled.push(d); return { device: d }; }; + return pooled; +} + +test('pools a HID-only Steam Puck even when the only live Gamepad-API pad is something else', async () => { + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const pooled = spyPool(m); + await withNavigator({ approvedHid: [PUCK], gamepads: [xboxPad(0)] }, () => m.autoPoolApprovedHid()); + assert.ok(pooled.includes(PUCK), 'Puck pooled despite no Gamepad-API liveness (no gate)'); +}); + +test('pools a known device with NO live Gamepad-API counterpart at all (gate removed)', async () => { + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const pooled = spyPool(m); + // Formerly treated as a "stale pairing" and skipped; now pooled. If it turns + // out to be absent, the phantom-eviction sweep in ingestFrame removes it. + await withNavigator({ approvedHid: [DS5], gamepads: [] }, () => m.autoPoolApprovedHid()); + assert.ok(pooled.includes(DS5), 'known device pooled regardless of Gamepad-API liveness'); +}); + +test('pools every approved known device (Steam + DualSense together)', async () => { + const m = new ControllerManager({ slotIds: ['P1', 'P2', 'P3'] }); + const pooled = spyPool(m); + await withNavigator({ approvedHid: [PUCK, DS5], gamepads: [] }, () => m.autoPoolApprovedHid()); + assert.ok(pooled.includes(PUCK) && pooled.includes(DS5), 'both pooled'); +}); + +test('ignores unknown devices', async () => { + const m = new ControllerManager({ slotIds: ['P1'] }); + const pooled = spyPool(m); + await withNavigator({ approvedHid: [UNKNOWN] }, () => m.autoPoolApprovedHid()); + assert.ok(!pooled.includes(UNKNOWN), 'unknown device not pooled'); +}); + +test('does not re-pool a device already in the pool', async () => { + const m = new ControllerManager({ slotIds: ['P1'] }); + // Pretend PUCK is already pooled. + m._hidPool.set(PUCK, { device: PUCK }); + const pooled = spyPool(m); + await withNavigator({ approvedHid: [PUCK] }, () => m.autoPoolApprovedHid()); + assert.ok(!pooled.includes(PUCK), 'already-pooled device skipped'); +}); From 49e5978a17babb7155ac8b8ec6d82b935e7b490b Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Wed, 8 Jul 2026 17:41:33 -0400 Subject: [PATCH 04/10] Keep idle Steam Puck interfaces pooled (fix power-on after launch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A controller powered on AFTER the app started wasn't picked up until a restart. Cause: the phantom-eviction sweep drops any pooled HID handle that hasn't streamed within the probation window — but a Steam Puck's receiver interfaces are present as long as the dongle is plugged, just idle until a body pairs. They were pooled at boot, evicted 3s later for silence, and when a body powered on it streamed into an already-enumerated interface (no WebHID 'connect' fires), so nothing re-pooled it. Fix: exempt fan-out (needsSiblingFanout / Steam Puck) interfaces from the silence sweep in both ControllerManager.ingestFrame and the overlay's evictPhantoms. A powered-on body now streams into the still-pooled interface and claims normally. Whole-Puck removal is still handled by the hotplug 'disconnect' path. +1 test (idle Puck interface kept past probation). 131 core tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW --- apps/overlay/src/js/app.js | 4 ++++ packages/core/src/manager.js | 8 ++++++++ packages/core/test/manager-pool-liveness.test.js | 16 ++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 1480de7..097aba4 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -1483,6 +1483,10 @@ function forwardControllerList() { const _PHANTOM_MS = 3000; function evictPhantoms(now) { for (const entry of [...listManager._hidPool.values()]) { + // Never evict a fan-out (Steam Puck) interface for silence: it's present as + // long as the dongle is plugged, just idle until a body powers on. Evicting + // it would drop a receiver slot that a controller is about to stream into. + if (entry.driver?.constructor?.needsSiblingFanout) continue; if (entry.lastRawReportAt === 0 && typeof entry.pooledAt === 'number' && (now - entry.pooledAt) > _PHANTOM_MS) { const dev = entry.device; console.log('[overlay] evicting phantom (no reports in ' + Math.round((now - entry.pooledAt)) + 'ms):', diff --git a/packages/core/src/manager.js b/packages/core/src/manager.js index 9674de0..f3b0b0c 100644 --- a/packages/core/src/manager.js +++ b/packages/core/src/manager.js @@ -1179,6 +1179,14 @@ export class ControllerManager { // it are never swept. for (const [device, entry] of [...this._hidPool]) { if (typeof entry.pooledAt !== 'number') continue; + // Fan-out (Steam Puck) interfaces are present as long as the dongle is + // plugged, even before any body is paired/streaming — an idle receiver + // slot is NOT a ghost. Evicting it breaks power-on-after-launch: the + // controller streams into an already-enumerated interface, which fires no + // WebHID 'connect', so nothing re-pools it and the user must restart the + // app. Real removal comes from the hotplug 'disconnect' path (whole Puck + // unplugged), not this silence sweep. See [[multi-steam-controller]]. + if (entry.driver?.constructor?.needsSiblingFanout) continue; if (entry.lastRawReportAt === 0 && (now - entry.pooledAt) > this.opts.poolProbationMs) { console.log(`[manager] evicting phantom HID handle ${device.vendorId?.toString(16)}:${device.productId?.toString(16)} (${device.productName || '?'}) — silent for ${Math.round(now - entry.pooledAt)}ms`); this._evictFromPool(device); diff --git a/packages/core/test/manager-pool-liveness.test.js b/packages/core/test/manager-pool-liveness.test.js index ac67106..520c0b3 100644 --- a/packages/core/test/manager-pool-liveness.test.js +++ b/packages/core/test/manager-pool-liveness.test.js @@ -56,3 +56,19 @@ test('test doubles without a numeric pooledAt are never swept', () => { m.ingestFrame([], 999999); assert.equal(m._hidPool.has(d), true, 'entries without pooledAt are left alone'); }); + +test('a fan-out (Steam Puck) interface is NOT evicted for silence — it waits for a body', () => { + // An idle Puck interface (receiver slot with no body paired yet) streams no + // reports, but it is present, not a ghost. Evicting it breaks power-on after + // launch. Removal is via hotplug disconnect (whole Puck unplugged), not this + // silence sweep. See [[multi-steam-controller]]. + class FanoutDriver {} + FanoutDriver.needsSiblingFanout = true; + const m = new ControllerManager({ slotIds: ['P1', 'P2'] }); + const puck = { vendorId: 0x28de, productId: 0x1304, productName: 'Steam Controller Puck' }; + const e = fakeEntry(puck, { pooledAt: 1000, lastRawReportAt: 0 }); // never streamed + e.driver = new FanoutDriver(); + m._hidPool.set(puck, e); + m.ingestFrame([], 1000 + m.opts.poolProbationMs + 5000); // well past probation + assert.equal(m._hidPool.has(puck), true, 'idle Puck interface kept, not swept as a phantom'); +}); From 2bdd484273a9cf55420a6e3d4091c90f848697a3 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Wed, 8 Jul 2026 17:56:54 -0400 Subject: [PATCH 05/10] Redesign multi app: dynamic PLAYER n roster + AVAILABLE list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fixed 4-panel "Press to join" layout with a dynamic roster: - Manager pre-allocates MAX_PLAYERS (16) slots; a PLAYER n panel (its own 3D ControllerOverlay) is created only when a controller CLAIMS a slot and disposed on release, so the grid grows/shrinks with active players and GPU cost scales with real players, not the cap. Slot ordinal = player number, so numbers are sticky across a brief drop (orphan→reconnect). - Views are reconciled to slot state each frame (not via claim-time events), so a panel is correct however the slot was claimed/released; the constructor renders current state directly since the view is created the frame after the claim. - Per-player accent colors generated (golden-angle hue) for all 16. - New AVAILABLE/PLAYER roster List, toggled by a "List" button next to Debug: every claimed controller shows as PLAYER n, every pooled streaming controller as AVAILABLE; idle Steam Puck sibling interfaces are hidden; duplicate names get #1/#2. This is the "recognize power-on as AVAILABLE, button press as a player" view Pete asked for. - Empty-state prompt when no one has joined; status line updated for the dynamic model; Steam label ("Steam Controller connected"). - WebHID-first boot + periodic re-pool (RESCAN_MS) so a controller powered on after launch is picked up without a restart (belt-and-suspenders with the fan-out eviction exemption). 131 core tests pass; multi-app is app-layer (not core-tested). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW --- apps/overlay/src/js/multi-app.js | 370 +++++++++++++++++++------------ apps/overlay/src/multi.html | 95 +++++++- 2 files changed, 323 insertions(+), 142 deletions(-) diff --git a/apps/overlay/src/js/multi-app.js b/apps/overlay/src/js/multi-app.js index 2901cec..ed3369a 100644 --- a/apps/overlay/src/js/multi-app.js +++ b/apps/overlay/src/js/multi-app.js @@ -1,34 +1,61 @@ // ============================================================ -// MULTI-APP.JS — Multi-controller overlay view layer +// MULTI-APP.JS — Dynamic multi-controller overlay view layer // ============================================================ // -// Thin view on top of shared/controller-manager.js. The manager owns -// all slot state, claim/release lifecycle, HID pairing, and sensor -// fusion. This file only: -// - instantiates 3D ControllerOverlay canvases -// - wires slot state changes to DOM text/classes -// - runs the raf loop and forwards pads + gyro to overlays -// - renders diagnostics panels + debug strip +// Thin view on top of controller-core's ControllerManager. The manager owns all +// slot state, claim/release lifecycle, HID pairing, and sensor fusion. This file +// only renders it. // -// See shared/controller-manager.js for the state machine, #222 for -// the design doc, #224 for the consolidation plan. +// DESIGN (dynamic roster): there are no fixed "Press to join" panels. The +// manager holds a generous pool of empty slots (MAX_PLAYERS); a PLAYER n panel +// is created the moment a controller CLAIMS a slot (goes ACTIVE) and disposed +// when it releases — so the grid grows/shrinks with the number of active +// players. Every connected-but-idle controller shows in the AVAILABLE list +// (toggled by the List button); pressing a button on one promotes it to its own +// PLAYER panel. The slot ordinal is the player number, so numbers are sticky +// across a brief drop (the manager's orphan→reconnect keeps the seat). +// +// WebHID-first: controllers are pooled over WebHID (autoPoolApprovedHid, gate- +// free) and the manager's ingestFrame claims them; the Gamepad API / XInput is +// the fallback for pads without a WebHID driver (Xbox). A periodic re-pool picks +// up a controller powered on AFTER launch. See [[multi-steam-controller]]. // ============================================================ import { ControllerOverlay, detectControllerType } from '@usersfirst/controller-visualizer'; -import { ControllerRegistry, ControllerManager, gamepadHasActivity } from '@usersfirst/controller-core'; +import { ControllerRegistry, ControllerManager } from '@usersfirst/controller-core'; + +// Pre-allocated slot cap. Panels/canvases are only created for CLAIMED slots, so +// the real cost scales with active players, not this number. 16 is well past a +// practical local-multiplayer ceiling; bump if ever needed. +const MAX_PLAYERS = 16; +const SLOT_IDS = Array.from({ length: MAX_PLAYERS }, (_, i) => `P${i + 1}`); -const SLOT_IDS = ['P1', 'P2', 'P3', 'P4']; +// Re-pool interval: a controller powered on after launch streams into an +// already-enumerated interface (no WebHID 'connect' event), so we periodically +// re-run autoPoolApprovedHid to pick it up. Cheap (getDevices + a set check). +const RESCAN_MS = 3000; const manager = new ControllerManager({ slotIds: SLOT_IDS }); -// Expose for DevTools debugging. -window.__manager = manager; +window.__manager = manager; // DevTools + +// ── Per-player accent color (generated for all MAX_PLAYERS) ── +// Golden-angle hue spacing keeps 16 players visually distinct. Applied inline so +// we don't need a CSS class per player. +function playerHue(playerNum) { return Math.round(((playerNum - 1) * 137.508) % 360); } +function applyPlayerColor(root, titleEl, playerNum) { + const h = playerHue(playerNum); + root.style.borderColor = `hsla(${h}, 72%, 62%, 0.45)`; + root.style.boxShadow = `0 0 30px hsla(${h}, 72%, 52%, 0.18) inset`; + if (titleEl) titleEl.style.color = `hsl(${h}, 72%, 72%)`; +} -// ── View: per-slot DOM wiring ── +// ── View: per-slot DOM wiring (created on claim, disposed on release) ── class SlotView { - constructor(slot, root) { + constructor(slot, root, playerNum) { this.slot = slot; this.root = root; + this.playerNum = playerNum; this.canvas = root.querySelector('[data-role="canvas"]'); this.promptEl = root.querySelector('[data-role="prompt"]'); this.hintEl = root.querySelector('[data-role="hint"]'); @@ -42,14 +69,13 @@ class SlotView { this.overlay = null; this.controllerType = 'dualsense'; this._diagLastUpdate = 0; + this._unsub = null; - root.classList.toggle('p1', slot.id === 'P1'); - root.classList.toggle('p2', slot.id === 'P2'); - root.classList.toggle('p3', slot.id === 'P3'); - root.classList.toggle('p4', slot.id === 'P4'); root.setAttribute('data-slot', slot.id); const titleEl = root.querySelector('.slot-title'); - if (titleEl) titleEl.textContent = `Player ${slot.id.slice(1)}`; + this.titleEl = titleEl; + if (titleEl) titleEl.textContent = `Player ${playerNum}`; + applyPlayerColor(root, titleEl, playerNum); if (this.connectBtn) { this.connectBtn.addEventListener('click', () => { @@ -74,8 +100,12 @@ class SlotView { }); } - slot.on((s, reason, data) => this._onSlotChange(reason, data)); - this._renderEmpty(); + this._unsub = slot.on((s, reason, data) => this._onSlotChange(reason, data)); + // The view is created the frame AFTER the claim, so we missed the initial + // 'claimed'/'hid-bound' events — render current state directly instead. + this._renderClaimed(); + this._renderHidBadge(!!slot._hidEntry); + if (slot.state === 'orphan') { this.root.classList.add('orphan'); this.hintEl.textContent = 'Reconnecting…'; } } async initOverlay(controllerType) { @@ -89,6 +119,13 @@ class SlotView { this._resize(); } + dispose() { + if (this._unsub) { try { this._unsub(); } catch {} this._unsub = null; } + try { this.overlay?.dispose?.(); } catch {} + this.overlay = null; + this.root.remove(); + } + _resize() { if (!this.overlay || !this.canvas) return; const w = this.canvas.clientWidth; @@ -100,11 +137,9 @@ class SlotView { const s = this.slot; switch (reason) { case 'claimed': + this.root.classList.remove('orphan'); this._renderClaimed(); break; - case 'released': - this._renderEmpty(); - break; case 'orphaned': this.hintEl.textContent = 'Reconnecting…'; this.root.classList.add('orphan'); @@ -119,23 +154,14 @@ class SlotView { this._renderRing(s.ringPct); break; case 'hid-report': - // Forward touchpad data to the 3D overlay. if (data?.touchpad && this.overlay?.updateTouchpad) { this.overlay.updateTouchpad(data.touchpad, data.touchpadButton); } break; + // 'released' is handled by the loop's view reconciliation (disposes us). } } - _renderEmpty() { - this.root.classList.remove('claimed', 'orphan'); - this.root.classList.add('empty'); - this.promptEl.innerHTML = 'Press any button to join'; - this.hintEl.textContent = ''; - this.subEl.textContent = ''; - this._renderRing(0); - } - _renderClaimed() { const s = this.slot; this.root.classList.remove('empty', 'orphan'); @@ -144,9 +170,6 @@ class SlotView { this.hintEl.textContent = 'Hold PS/Home 2s to release'; this.subEl.textContent = (s.controllerId || '').slice(0, 28); - // Swap 3D model to match the claimed controller. Prefer the dictionary - // entry's controllerProfile (so a registered clone with its own GLB - // wins) and fall back to the visualizer's id-pattern sniff. const idInfo = ControllerRegistry.identifyFromGamepadId(s.controllerLabel || ''); const desired = idInfo?.controllerProfile || detectControllerType(s.controllerLabel || '') || 'dualsense'; if (desired !== this.controllerType && this.overlay) { @@ -173,54 +196,177 @@ class SlotView { if (this.calibBtn) this.calibBtn.style.display = on ? '' : 'none'; } - /** - * Called each frame. Pushes the effective gamepad + gyro quaternion into - * the 3D overlay, and updates the calibration-finished hint text. - */ tick(pads) { const s = this.slot; - // Calibration-complete transition const isCalibrating = !!(s.fusion && s.fusion.calibrating); if (s._wasCalibrating && !isCalibrating) { s.fusion?.reset(); - this.hintEl.textContent = s.state === 'claimed' - ? 'Hold PS/Home 2s to release' - : ''; + this.hintEl.textContent = s.state === 'claimed' ? 'Hold PS/Home 2s to release' : ''; } s._wasCalibrating = isCalibrating; const gp = s.effectiveGamepad(pads); - // Gyro only applies once the slot is claimed — unclaimed slots stay - // still even though the fusion keeps running in the background. const gyroQ = (s.state === 'claimed' && s.fusion) ? s.fusion.orientation : null; if (this.overlay) this.overlay.update(gp, gyroQ); } } function labelForGamepad(gamepadIdString) { - const type = detectControllerType(gamepadIdString || ''); + const idInfo = ControllerRegistry.identifyFromGamepadId(gamepadIdString || ''); + const type = idInfo?.protocol || detectControllerType(gamepadIdString || ''); + if (type === 'steam-controller') return 'Steam Controller connected'; if (type === 'dualsense') return 'DualSense connected'; if (type === 'switch-pro') return 'Switch Pro connected'; if (type === 'xbox') return 'Xbox Controller connected'; return 'Controller connected'; } -// ── Diagnostics panel ── +// ── Dynamic view lifecycle ── +// A PLAYER panel exists iff its slot is non-empty. Reconciled each frame so it +// stays correct however the slot got claimed/released (button press, PS-hold, +// hot-plug, reconnect) without threading events through view creation. + +const slotsContainer = document.getElementById('slots'); +const slotTemplate = document.getElementById('slot-template'); +const emptyState = document.getElementById('empty-state'); +const views = new Map(); // slotId -> SlotView + +function playerNumFor(slot) { return manager.slots.indexOf(slot) + 1; } + +function ensureView(slot) { + if (views.has(slot.id)) return views.get(slot.id); + const fragment = slotTemplate.content.cloneNode(true); + const root = fragment.querySelector('.slot'); + slotsContainer.appendChild(fragment); + const view = new SlotView(slot, root, playerNumFor(slot)); + views.set(slot.id, view); + // Model + gyro: derive the profile from the controller that just claimed. + const idInfo = ControllerRegistry.identifyFromGamepadId(slot.controllerLabel || ''); + const type = idInfo?.controllerProfile || detectControllerType(slot.controllerLabel || '') || 'dualsense'; + view.initOverlay(type).catch((err) => console.error(`[${slot.id}] overlay init failed`, err)); + return view; +} + +function disposeView(slot) { + const view = views.get(slot.id); + if (!view) return; + view.dispose(); + views.delete(slot.id); +} + +function reconcileViews() { + for (const slot of manager.slots) { + const shouldShow = slot.state !== 'empty'; + if (shouldShow && !views.has(slot.id)) ensureView(slot); + else if (!shouldShow && views.has(slot.id)) disposeView(slot); + } + if (emptyState) emptyState.classList.toggle('hidden', views.size > 0); +} + +// ── AVAILABLE / PLAYER roster list (toggled by the List button) ── + +const listPanel = document.getElementById('controller-list'); +const listBody = document.getElementById('controller-list-body'); +const listCount = document.getElementById('controller-list-count'); +const listToggle = document.getElementById('list-toggle'); +if (listToggle && listPanel) { + listToggle.addEventListener('click', () => { + const open = listPanel.classList.toggle('hidden'); + listToggle.classList.toggle('open', !open); + }); +} + +function ctrlName(device) { + const e = ControllerRegistry.getEntry(device.vendorId, device.productId); + if (e && e.name) return e.name; + return device.productName || 'Controller'; +} +function vpStr(d) { + return `${(d.vendorId || 0).toString(16).padStart(4, '0')}:${(d.productId || 0).toString(16).padStart(4, '0')}`; +} +function synthActive(gp) { + if (!gp) return false; + for (const b of (gp.buttons || [])) if (b && (b.pressed || (b.value || 0) > 0.5)) return true; + for (const a of (gp.axes || [])) if (Math.abs(a) > 0.5) return true; + return false; +} + +// Build the roster: every claimed controller (as PLAYER n) + every pooled, +// streaming controller (as AVAILABLE). Fan-out (Steam Puck) idle sibling +// interfaces are hidden — only a streaming interface is a real controller. +function rosterRows() { + const rows = []; + for (const slot of manager.slots) { + if (slot.state === 'empty') continue; + const d = slot._hidEntry?.device || null; + rows.push({ + name: d ? ctrlName(d) : (labelForGamepad(slot.controllerLabel) || 'Controller'), + vp: d ? vpStr(d) : '—', + state: slot.state === 'orphan' ? `PLAYER ${playerNumFor(slot)} (reconnecting)` : `PLAYER ${playerNumFor(slot)}`, + active: slot._hidEntry ? synthActive(slot._hidEntry.synthetic) : false, + sort: playerNumFor(slot), + }); + } + let avail = 1000; + for (const entry of manager._hidPool.values()) { + const d = entry.device; + const isFanout = !!entry.driver?.constructor?.needsSiblingFanout; + if (isFanout && !(entry.hidActiveSince > 0)) continue; // idle Puck sibling — not a controller + rows.push({ + name: ctrlName(d), + vp: vpStr(d), + state: 'AVAILABLE', + active: synthActive(entry.synthetic), + sort: avail++, + }); + } + rows.sort((a, b) => a.sort - b.sort); + // Disambiguate identical names (two Steam bodies share name+vid:pid). + const counts = new Map(); + for (const r of rows) counts.set(r.name, (counts.get(r.name) || 0) + 1); + const seen = new Map(); + for (const r of rows) { + if (counts.get(r.name) > 1) { const n = (seen.get(r.name) || 0) + 1; seen.set(r.name, n); r.name = `${r.name} #${n}`; } + } + return rows; +} -function esc(s) { - return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - })[c]); +let _listLastUpdate = 0; +function renderRoster(now) { + if (!listBody || !listPanel || listPanel.classList.contains('hidden')) return; + if (now - _listLastUpdate < 200) return; // ~5Hz + _listLastUpdate = now; + const rows = rosterRows(); + if (listCount) listCount.textContent = String(rows.length); + if (rows.length === 0) { + listBody.innerHTML = '
No controllers connected. Power one on.
'; + return; + } + listBody.innerHTML = rows.map((r) => { + const isPlayer = r.state.startsWith('PLAYER'); + const dotCls = r.active ? 'on' : (isPlayer ? 'player' : ''); + const stateCls = isPlayer ? 'player' : 'avail'; + return `
+ + ${escapeHtml(r.name)} + ${escapeHtml(r.vp)} + ${escapeHtml(r.state)} +
`; + }).join(''); } -function hex4(n) { - return n != null ? '0x' + n.toString(16).padStart(4, '0') : '—'; +function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } +// ── Diagnostics panel (per PLAYER) ── + +function hex4(n) { return n != null ? '0x' + n.toString(16).padStart(4, '0') : '—'; } + function renderSlotDiagnostics(view, pads) { if (!view.diagEl || view.diagEl.hasAttribute('hidden')) return; const now = performance.now(); - if (now - view._diagLastUpdate < 160) return; // throttle ~6Hz + if (now - view._diagLastUpdate < 160) return; view._diagLastUpdate = now; const s = view.slot; @@ -228,40 +374,23 @@ function renderSlotDiagnostics(view, pads) { const synth = s.synthetic; const hid = s.hidDevice; const driver = s.driver; - const gpInfo = gp ? ControllerRegistry.identifyFromGamepadId(gp.id) : null; - const pressedGp = gp ? [...gp.buttons].map((b, i) => b?.pressed ? i : -1).filter((i) => i >= 0) : []; const pressedSynth = synth ? synth.buttons.map((b, i) => b.pressed ? i : -1).filter((i) => i >= 0) : []; - const axesGp = gp ? [...gp.axes].map((a) => a.toFixed(2)).join(', ') : ''; const axesSynth = synth ? synth.axes.map((a) => a.toFixed(2)).join(', ') : ''; const lines = []; - lines.push(`── Gamepad API ──`); - if (gp) { - lines.push(`id ${esc(gp.id)}`); - lines.push(`mapping ${esc(gp.mapping || '(empty)')}${gp.mapping === 'standard' ? ' ' : ' non-standard'}`); - lines.push(`index ${gp.index} buttons ${gp.buttons.length} axes ${gp.axes.length}`); - lines.push(`driver? ${gpInfo ? `${esc(gpInfo.driverName)} gyro=${gpInfo.hasGyro} tp=${gpInfo.hasTouchpad}` : 'no match'}`); - lines.push(`pressed [${pressedGp.join(',')}]`); - lines.push(`axes [${esc(axesGp)}]`); - } else { - lines.push(`no pad at this slot's index (${s.gamepadIndex ?? 'none'})`); - } - - lines.push(''); lines.push(`── WebHID ──`); if (hid) { - lines.push(`product ${esc(hid.productName || '(unnamed)')}`); + lines.push(`product ${escapeHtml(hid.productName || '(unnamed)')}`); lines.push(`vid:pid ${hex4(hid.vendorId)}:${hex4(hid.productId)}`); - lines.push(`driver ${esc(driver?.entry?.name || '?')} conn ${esc(driver?.connectionType || '?')}`); - const caps = driver?.entry?.capabilities || {}; - lines.push(`caps gyro=${caps.gyro} accel=${caps.accel} touchpad=${caps.touchpad}`); + lines.push(`driver ${escapeHtml(driver?.entry?.name || '?')} conn ${escapeHtml(driver?.connectionType || '?')}`); const gyroOk = s.fusion && s.fusion._lastGyroTime > 0 && !s.fusion.calibrating; lines.push(`gyro ${gyroOk ? 'integrating' : (s.fusion?.calibrating ? 'calibrating…' : 'idle')}`); - lines.push(`synth pressed=[${pressedSynth.join(',')}] axes=[${esc(axesSynth)}]`); + lines.push(`synth pressed=[${pressedSynth.join(',')}] axes=[${escapeHtml(axesSynth)}]`); } else { lines.push(`no HID bound`); } + if (gp) lines.push(`gamepad ${escapeHtml(gp.id)}`); view.diagEl.innerHTML = lines.join('\n'); } @@ -270,80 +399,37 @@ function renderSlotDiagnostics(view, pads) { const debugEl = document.getElementById('debug-readout'); -function renderDebugStrip(pads, views) { - if (!debugEl) return; - const lines = [`pads array length: ${pads.length}`]; - let seen = 0; - for (let i = 0; i < pads.length; i++) { - const gp = pads[i]; - if (!gp) { lines.push(` [${i}] null`); continue; } - seen++; - const pressed = []; - for (let b = 0; b < gp.buttons.length; b++) { - if (gp.buttons[b]?.pressed) pressed.push(b); - } - lines.push(` [${i}] ${gp.id.slice(0, 40)} map=${gp.mapping} pressed=[${pressed.join(',')}]`); - } - if (seen === 0) lines.push(' (no gamepads visible via Gamepad API)'); - for (const v of views) { +function renderDebugStrip(pads) { + if (!debugEl || debugEl.classList.contains('hidden')) return; + const lines = [`slots claimed: ${views.size}/${MAX_PLAYERS}`]; + for (const v of views.values()) { const s = v.slot; - if (s.synthetic) { - const pressed = []; - for (let b = 0; b < s.synthetic.buttons.length; b++) { - if (s.synthetic.buttons[b].pressed) pressed.push(b); - } - lines.push(` [${s.id} HID] ${s.hidDevice?.productName || '?'} pressed=[${pressed.join(',')}]`); - } + const pressed = []; + if (s.synthetic) for (let b = 0; b < s.synthetic.buttons.length; b++) if (s.synthetic.buttons[b].pressed) pressed.push(b); + lines.push(` Player ${v.playerNum} [${s.state}] ${s.hidDevice?.productName || '?'} pressed=[${pressed.join(',')}]`); } - // Pooled HID devices (paired but not yet bound to a slot) - if (manager._hidPool.size > 0) { - for (const entry of manager._hidPool.values()) { - const pressed = []; - for (let b = 0; b < entry.synthetic.buttons.length; b++) { - if (entry.synthetic.buttons[b].pressed) pressed.push(b); - } - lines.push(` [pool] ${entry.device.productName || '?'} pressed=[${pressed.join(',')}]`); - } + lines.push(`pool (available): ${manager._hidPool.size}`); + for (const entry of manager._hidPool.values()) { + const streaming = entry.hidActiveSince > 0; + const fan = entry.driver?.constructor?.needsSiblingFanout ? ' fanout' : ''; + lines.push(` [pool] ${entry.device.productName || '?'} ${vpStr(entry.device)}${fan} streaming=${streaming}`); } - lines.push(`slots: ${views.map((v) => `${v.slot.id}=${v.slot.state}`).join(' ')}`); + const seen = pads.filter(Boolean).length; + lines.push(`gamepad-api pads: ${seen}`); debugEl.textContent = lines.join('\n'); } // ── Wiring ── -const slotsContainer = document.getElementById('slots'); -const slotTemplate = document.getElementById('slot-template'); - -const views = manager.slots.map((slot) => { - const fragment = slotTemplate.content.cloneNode(true); - const root = fragment.querySelector('.slot'); - slotsContainer.appendChild(fragment); - return new SlotView(slot, root); -}); - async function boot() { - for (const v of views) { - try { - await v.initOverlay('dualsense'); - } catch (err) { - console.error(`[${v.slot.id}] overlay init failed`, err); - } - } - window.addEventListener('resize', () => views.forEach((v) => v._resize())); - window.addEventListener('gamepadconnected', (e) => { - console.log('gamepadconnected', e.gamepad.index, e.gamepad.id); - }); - window.addEventListener('gamepaddisconnected', (e) => { - console.log('gamepaddisconnected', e.gamepad.index, e.gamepad.id); - }); if (navigator.hid) { - // Already-approved controllers pool gesture-free; new ones pair via the - // per-slot Connect button (requestDevice needs a user gesture); hot-plug - // of approved devices is handled by wireHidHotplug. + // WebHID-first: pool every approved, known device (gate-free). Hot-plug of + // approved devices + the periodic rescan keep the pool fresh. await manager.autoPoolApprovedHid(); manager.wireHidHotplug(); + setInterval(() => { manager.autoPoolApprovedHid().catch(() => {}); }, RESCAN_MS); } loop(); @@ -354,13 +440,15 @@ function loop() { const pads = (navigator.getGamepads && navigator.getGamepads()) || []; manager.ingestFrame(pads, now); + reconcileViews(); - for (const v of views) { + for (const v of views.values()) { v.tick(pads); renderSlotDiagnostics(v, pads); } - renderDebugStrip(pads, views); + renderRoster(now); + renderDebugStrip(pads); requestAnimationFrame(loop); } diff --git a/apps/overlay/src/multi.html b/apps/overlay/src/multi.html index cd03e8f..360c1a9 100644 --- a/apps/overlay/src/multi.html +++ b/apps/overlay/src/multi.html @@ -266,6 +266,68 @@ letter-spacing: 0.6px; pointer-events: none; } + + /* Empty state — shown when no players have joined yet */ + #empty-state { + position: fixed; + top: 36px; left: 0; right: 0; bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + pointer-events: none; + z-index: 5; + } + #empty-state.hidden { display: none; } + #empty-state .es-icon { font-size: 46px; opacity: 0.5; margin-bottom: 14px; } + #empty-state .es-title { + font-size: 15px; font-weight: 700; letter-spacing: 1.5px; + text-transform: uppercase; color: #8a93a7; + } + #empty-state .es-sub { font-size: 12px; color: #5b6374; margin-top: 8px; line-height: 1.6; } + + /* Controller roster list (toggled by the List button) */ + #controller-list { + position: fixed; + top: 36px; right: 10px; + width: min(360px, 80vw); + max-height: 70vh; + overflow: auto; + background: rgba(6,9,16,0.94); + border: 1px solid rgba(120,180,255,0.22); + border-radius: 10px; + z-index: 205; + backdrop-filter: blur(6px); + padding: 10px 8px 8px 8px; + } + #controller-list.hidden { display: none; } + #controller-list .cl-head { + font-size: 10px; letter-spacing: 1.2px; text-transform: uppercase; + color: #6a7184; padding: 0 8px 8px 8px; + } + #controller-list-body { display: flex; flex-direction: column; gap: 2px; } + .cl-row { + display: flex; align-items: center; gap: 8px; + padding: 7px 8px; border-radius: 6px; + font-size: 12px; color: #cfd6e4; + } + .cl-row:hover { background: rgba(255,255,255,0.04); } + .cl-dot { + width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; + background: rgba(255,255,255,0.18); + } + .cl-dot.player { background: #5fd184; box-shadow: 0 0 6px rgba(95,209,132,0.6); } + .cl-dot.on { background: #ffd166; box-shadow: 0 0 7px rgba(255,209,102,0.8); } + .cl-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .cl-vp { font-family: monospace; font-size: 10px; color: #5b6374; flex: 0 0 auto; } + .cl-state { + flex: 0 0 auto; font-size: 9px; letter-spacing: 0.6px; text-transform: uppercase; + padding: 2px 7px; border-radius: 8px; + } + .cl-state.avail { background: rgba(120,150,200,0.16); color: #9db4da; } + .cl-state.player { background: rgba(95,209,132,0.18); color: #8be0a8; } + .cl-empty { padding: 12px 10px; font-size: 12px; color: #5b6374; text-align: center; } @@ -275,6 +337,12 @@
+
+
🎮
+
Waiting for players
+
Power on a controller, then press any button to join.
Open List to see connected controllers.
+
+ -
Hold PS/Home 2s to release a slot · First input claims next empty slot
+
Press any button on a connected controller to join · Hold PS/Home 2s to leave · Open List to see all controllers
+ + + + + + +
+
PLAYERS & CONTROLLERS
+
+
● player · ● in use now · press a button to join · drag to move
+
+ + + + + diff --git a/apps/overlay/src/multi.html b/apps/overlay/src/multi.html index 360c1a9..39efcc2 100644 --- a/apps/overlay/src/multi.html +++ b/apps/overlay/src/multi.html @@ -286,48 +286,6 @@ text-transform: uppercase; color: #8a93a7; } #empty-state .es-sub { font-size: 12px; color: #5b6374; margin-top: 8px; line-height: 1.6; } - - /* Controller roster list (toggled by the List button) */ - #controller-list { - position: fixed; - top: 36px; right: 10px; - width: min(360px, 80vw); - max-height: 70vh; - overflow: auto; - background: rgba(6,9,16,0.94); - border: 1px solid rgba(120,180,255,0.22); - border-radius: 10px; - z-index: 205; - backdrop-filter: blur(6px); - padding: 10px 8px 8px 8px; - } - #controller-list.hidden { display: none; } - #controller-list .cl-head { - font-size: 10px; letter-spacing: 1.2px; text-transform: uppercase; - color: #6a7184; padding: 0 8px 8px 8px; - } - #controller-list-body { display: flex; flex-direction: column; gap: 2px; } - .cl-row { - display: flex; align-items: center; gap: 8px; - padding: 7px 8px; border-radius: 6px; - font-size: 12px; color: #cfd6e4; - } - .cl-row:hover { background: rgba(255,255,255,0.04); } - .cl-dot { - width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; - background: rgba(255,255,255,0.18); - } - .cl-dot.player { background: #5fd184; box-shadow: 0 0 6px rgba(95,209,132,0.6); } - .cl-dot.on { background: #ffd166; box-shadow: 0 0 7px rgba(255,209,102,0.8); } - .cl-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .cl-vp { font-family: monospace; font-size: 10px; color: #5b6374; flex: 0 0 auto; } - .cl-state { - flex: 0 0 auto; font-size: 9px; letter-spacing: 0.6px; text-transform: uppercase; - padding: 2px 7px; border-radius: 8px; - } - .cl-state.avail { background: rgba(120,150,200,0.16); color: #9db4da; } - .cl-state.player { background: rgba(95,209,132,0.18); color: #8be0a8; } - .cl-empty { padding: 12px 10px; font-size: 12px; color: #5b6374; text-align: center; } @@ -375,11 +333,6 @@ - -