WebHID-first controller support: seat-select lobby + overlay picker + single-owner pool#102
Merged
Conversation
New sibling overlay view built on the controller-core slot lifecycle: mode select -> per-controller join -> ready gate -> CHOOSE LEVEL, with DualSense lightbar + rumble feedback per seat/team. Becomes Tandemonium's real lobby; reaches the game via sync-controller-core. - apps/overlay/src/lobby.html, src/js/lobby-app.js -- the view - electron/main.js: --lobby flag -> lobby.html (LOBBY_WINDOW_SIZE, stateKey 'lobby') - package.json: start:lobby / prestart:lobby scripts WebHID hardening (Puck lizard-mode churn, DualSense BT handshake) tracked separately in packages/core. Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD
🔎 PR #102 preview — torn downThis PR is closed; its web preview and desktop prerelease have been removed. |
The Puck exposes 5 HID interfaces sharing vid:pid, each pooled as its own driver instance. Previously every instance's init() probed all 5 siblings and ran its own 800ms heartbeat -> ~25 SET_REPORT probes on connect and 25 feature-writes per 800ms, most failing NotAllowedError. That churn janked the renderer ~4s and made the DualSense/Steam pads feel unrecognized. Elect a single lizard-mode owner per Puck (keyed by vid:pid): only the first instance probes + heartbeats. The owner already broadcasts CLEAR to all 5 siblings, so suppression coverage is unchanged -- just 5x less work. Ownership is released on destroy() so a re-pool re-elects. Adds owner-election regression tests (exactly-one-timer; re-elect on owner destroy). Full core suite green (95). Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD
On a still-negotiating hot-plug BT link the activating feature read (DS5 0x05 / DS4 0x02) throws, but _activateFullReportMode still waited ~450ms per attempt (5 attempts) for a 0x31/0x11 report that could never arrive — ~2s of dead init that also serialized the pool loads queued behind it. - Skip the wait when the activating read THROWS (link not ready): bail fast to the background loop instead of blocking. Reads that are ACCEPTED still wait, since that's the "device got the command, give it a beat" case. - Trim inline attempts 5->3 and perAttemptMs 450->300; keep the cold-plug fast path (first accepted read + 0x31 within ms). - Background re-activation now nudges immediately (was idling a full interval first) and polls every 500ms (was 1000ms), so gyro recovers sooner once the link settles. Adds handshake tests (thrown-read skips wait / accepted-read activates / background nudges immediately). Core suite green (98). Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD
WebHID requestDevice() requires transient user activation, so the boot-time
fire-and-forget call always threw "Must be handling a user gesture to show a
permission request" and pooled nothing -- dead code that only logged a scary
error. There is no gesture-free way to grant a NOT-yet-approved device at
boot, so remove the attempt.
Boot pairing now rests on the paths that actually work:
- already-approved devices -> autoPoolApprovedHid() (gesture-free)
- a new device -> connectHidForSlot() from a user gesture
(the overlay's per-slot Connect/Pair button)
- hot-plug of approved ones -> wireHidHotplug()
The method is kept as a documented no-op so downstream/synced callers (the
game's boot chain) don't break; overlay callers (multi-app, lobby-app) drop
the call. Regression test locks "no-op, never calls requestDevice". Core
suite green (99).
Refs #101
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD
So 'npm run start:lobby' works from the repo root, not only from apps/overlay. Delegates to @usersfirst/overlay's start:lobby (which runs its prestart copy-workspace + electron . --lobby). Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD
The overlay is a fresh WebHID origin, so grants from the game don't carry over and getDevices() returns nothing until you pair here — and the Pair button only lived on the lobby screen, so an HID-only Steam Controller couldn't get you off the mode screen (chicken-and-egg). Add a persistent top-right 'Pair controller' button (approves the next device; main.js auto-selects) and a live '<n> active / <n> paired' count visible from the mode screen, so controllers can be paired and seen immediately. DualSense still works via the Gamepad API without pairing; pairing adds its lightbar/gyro and is required for the Steam Controller. Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD
Make WebHID the authoritative input source for all HID controllers, with a manager-backed controller picker, per-unit identity, and full button parity. Controllers list / selection: - Detached, movable "Controllers" window (reuses HUD-window infra) listing every connected controller with live activity dots; click-to-select drives the overlay + auto-selects the 3D model (dropdown still overrides). - Manager-backed pool owns non-selected controllers; three states: AVAILABLE > ACTIVE (interacted) > SELECTED (the one shown). - Steam Puck's 5 HID interfaces roll up to one row; stable ordering (no reorder on select); selection binds the EXACT handle (identical vid:pid safe). - Auto-detect adopts only when nothing selected (startup + re-adopt on disconnect); pressing another controller never steals the selection. WebHID-first input (Phase 3a): - readGamepad() returns the HID synthetic whenever a HID driver is live (any transport), so every HID controller reads its exact handle with the complete button map. Gamepad API is now only a fallback for XInput-only pads (Xbox). Driver parsing / identity: - DualShock 4 buttons/sticks/triggers over WebHID (GameSir + real DS4). - Switch Pro buttons/sticks/triggers (report 0x30) + Capture button (slot 17); connection-type detection (USB 0x80 handshake report vs Bluetooth). - Per-unit serial/MAC per row; identical vid:pid disambiguated by connection type (Electron main HIDDevice.collections is empty, so no descriptor fp). - vid:pid disambiguation in identifyFromGamepadId. Tests: 116 passing (adds Switch Pro parse coverage). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
…ase 3b/1) A pooled (unclaimed) entry emits no slot 'hid-report', so its touchpad/grips weren't reachable. Store them on the entry so the overlay can read the SELECTED pool entry's report-level extras directly once it cuts over to the pool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
The pool is now the SOLE owner of every HID device; the overlay's SELECTED controller is a designated pool entry, not a second connection. Retires the direct-connect path in favor of the manager's per-controller fusion + synthetic (the same ones the game uses). - finishGyroConnect() DESIGNATES the pool entry for a device (no open/connect, no eviction, no separate inputreport listener). Steam Puck fan-out: designates the same-vid:pid sibling that's actually streaming STATE reports. - designateEntry() aliases hidDevice/controllerDriver/syntheticGamepad/gyroFusion to the entry and applies the overlay's gravity/yaw prefs to its fusion; undesignateEntry() points gyroFusion back at a neutral idle fusion. - disconnectGyro() undesignates (device stays pooled + live); never resets the entry's fusion. toggleGyro() OFF just stops displaying. - Calibration retired to the entry's fusion.startCalibration (SensorFusion already has the variance/retry logic the overlay was duplicating). - Touchpad/grips read from the selected entry each frame (manager stores them); handleInputReport is no longer attached. - initControllerList pools ALL granted devices (no eviction); overlayControllerRows skips the selected device's pool row. - readGamepad stays WebHID-authoritative (3a); Gamepad API only for XInput. NEEDS HARDWARE VERIFICATION (overlay is a complete test harness — the 3D model renders the same fusion orientation the game consumes): select each controller; tilt → model tracks 1:1; hold still → no drift; recenter; buttons/sticks/triggers; Switch Capture; touchpad/grips; Puck; switch between controllers; unplug the selected one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
The Phase 3b/2 edit left a stray duplicate of the _preferredGyroDevice + _deviceIds top-level declarations, throwing a renderer SyntaxError on load (renderer parse errors don't surface in main-process stdout, so the headless boot check missed it). node --check now clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
Two regressions from the 3b cutover, both from the SELECTED viz now reading the manager's per-controller synthetic/fusion: - Back paddles didn't light: the core synthetic stopped at 18 buttons, but the Steam profile maps the paddles to slots 18-21. Extend makeSyntheticGamepad to 22 buttons + applyParsedToSynthetic maps parsed.paddles (L4/L5/R4/R5) to 18-21. - "Drift then settles": the entry's one-shot bias calibration ran at pool time (boot, while the pad may be moving), so it aborted and left bias to the slower continuous stillness pass. designateEntry() now re-runs startCalibration on selection (pad usually at rest) for a tight window — matching the old connect-time calibration. Suite 116/116. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
…(3b) - USB DS4 dead: at boot it auto-connected BEFORE the 2s pool init, so finishGyroConnect pooled its own getDevices() handle; then initControllerList re-pooled a DIFFERENT getDevices() object for the same physical device → double-open → a dead duplicate entry the list row pointed at. Fix: await initControllerList() FIRST (pool every device once), and finishGyroConnect now NEVER re-pools — it prefers the exact handle, else a same-vid:pid entry that's actually receiving reports (hidActiveSince>0, a wire-level fact — NOT the user-facing ACTIVE state). - Gyro off / needs manual calibrate: the entry's fusion integrates from pool time (boot), so designateEntry now reset()s orientation + re-runs startCalibration on selection (pad usually at rest) for a tight bias window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
- L3+R3 recalibrate lost its orientation zeroing when startCalibration stopped calling resetGyroState; restore reset()+resetBias() so a recalibrate returns to the zero positions (still never touches the camera, #94). - Add a [designate] + per-pool-entry diagnostic (conn/rawReportAt/parsedAt) to pin down the USB DS4 "SELECTED but no data": rawReportAt=0 → the handle isn't receiving reports (double-open / wrong handle); parsedAt=0 with rawReportAt>0 → parseReport isn't producing data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
Diagnostics revealed the "USB DS4 SELECTED but dead" is a PHANTOM handle: the pooled 054c:05c4 (usb) entry had rawReportAt=0 (never received a report) and an empty productName — a stale/half-open grant, not a live controller. The manager drops these in ingestFrame (lastRawReportAt===0 past a grace window), but the overlay pools without ingestFrame, so phantoms lingered and auto-adopt/selection landed on the dead one. Add evictPhantoms() to the loop (3s grace); if the SELECTED device is the phantom, undesignate so auto-adopt finds a live handle. A physical replug then re-pools a fresh, streaming handle via wireHidHotplug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
…ssion (3b) Console showed a USB DS4 whose lastRawReportAt FROZE at a non-zero value while every other controller kept advancing — the handle hung yet stayed "connected" for ~2 min, so the overlay showed frozen gyro/buttons until Chromium finally fired disconnect. The manager's stuck-IMU heal needs reports flowing and phantom eviction only catches never-streamed (rawAt===0) handles, so neither caught a frozen stream. Add checkStalledStream(): if the SELECTED entry delivers no new raw report for 1.5s while gyro is active, re-init its driver (HidEntry _reinitDriver → driver.init + recalibrate) to kick the stream; 4s backoff between attempts. Healthy pads stream continuously (~100-250Hz) so no false hits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
…Ts (3b) Pressing the DS4 then having the untouched Switch Pro jump to SELECTED was the Gamepad API adopting a controller whose BT pad merely CONNECTED. Make auto-SELECT input-driven and pool-based: - autoAdoptFromPool() (run each frame while nothing is SELECTED): designate ONLY a controller the user has ENGAGED (_everPressed + receiving). An untouched pad, however it connects, is never auto-selected. - gamepadconnected / checkForExistingGamepad: skip pads that are known HID controllers (in the pool) — only XInput (Xbox) pads are adopted via the Gamepad API. HID adoption goes through the pool. - readGamepad no longer probes+switches on a present pad; removed the startup bootstrapFromHID auto-adopt. Result (per Pete): nothing SELECTED at boot → the "No Controller" splash stays until you press a controller or click its row. Model still auto-changes to the SELECTED controller with the Model dropdown left on Auto-detect (switchController reads the dropdown, never writes it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
The per-pool-entry dump was a temporary debug aid for the USB DS4 investigation; keep a concise one-liner (device / conn / receiving) for the field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
petegordon
added a commit
that referenced
this pull request
Jul 6, 2026
Bumps @usersfirst/controller-core after the seat-select lobby + overlay picker + single-owner pool merge (#102): DS4/DualSense/Steam/Switch Pro parsing (buttons, sticks, triggers, Capture/paddles), per-unit identity, phantom/stall recovery. Tag core-v0.3.0 pins the vendored copy for downstream (Tandemonium game). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Overhauls controller support across the overlay + core so WebHID is the single authoritative input source, with one controller model shared by the seat-select lobby and the single overlay. The Gamepad API is quarantined to XInput-only pads (Xbox).
What's included
Seat-select navigator (lobby)
ControllerManagerslot lifecycle.Single-overlay controllers picker
WebHID-first, single-owner pool (Phase 3)
fusion+synthetic), not a second connection. Retires the direct-connect path and the app-layer calibration duplication (SensorFusionalready self-calibrates).readGamepad()returns the HID synthetic for every HID controller — complete button map (Switch Capture / DualSense mic / back paddles), exact physical handle (identical vid:pid pads never cross-wire), immune to Gamepad-API stale-slot freezes. XInput pads (Xbox) use the Gamepad API fallback.Driver + identity (core)
identifyFromGamepadId;ControllerDriver.needsSiblingFanout.Robustness
Tests
116 passing. Adds DS4 / Switch Pro parse coverage, manager pool-liveness / release-slot / duplicate-controller fold+correlation, Steam lizard-owner, and PlayStation BT handshake tests.
Verification
Hardware-verified on DualShock 4 (USB + BT/GameSir), DualSense (BT), Steam Controller (Puck), and Switch Pro (BT). Because the 3D model renders the same fusion orientation the game's bike consumes, gyro/steering is verifiable directly in the overlay (tilt → model tracks 1:1; hold still → no drift).
🤖 Generated with Claude Code
https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY