Skip to content

WebHID-first controller support: seat-select lobby + overlay picker + single-owner pool#102

Merged
petegordon merged 17 commits into
mainfrom
feat/seat-select-navigator
Jul 6, 2026
Merged

WebHID-first controller support: seat-select lobby + overlay picker + single-owner pool#102
petegordon merged 17 commits into
mainfrom
feat/seat-select-navigator

Conversation

@petegordon

@petegordon petegordon commented Jul 5, 2026

Copy link
Copy Markdown
Member

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)

  • Local versus / co-op seat select on the ControllerManager slot lifecycle.
  • Four-state model: AVAILABLE → ACTIVE (controller layer) and ASSIGNED → READY (game layer). Recognize-first join; B steps out one level and stops at ACTIVE; PS/Home hold = universal release. ▲▼ swap Captain/Stoker.
  • Controllers panel with per-unit serial/MAC identity; always-visible Pair button + live count.

Single-overlay controllers picker

  • Detached, movable Controllers window listing every connected controller with live "in use" dots (including DualSense-BT / Steam Controller, which the Gamepad API can't see).
  • Click-to-select which controller drives the overlay; auto-selects its 3D model while the Model dropdown stays on Auto-detect. Per-unit serials shown; identical vid:pid disambiguated by connection type.

WebHID-first, single-owner pool (Phase 3)

  • The pool is the sole owner of every HID device; the SELECTED controller is a designated pool entry (its fusion + synthetic), not a second connection. Retires the direct-connect path and the app-layer calibration duplication (SensorFusion already 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.
  • Input-driven auto-select: nothing is SELECTED until the user engages a controller (presses it) or clicks its row — an idle/just-connected controller never auto-selects. "No Controller" splash until engaged.

Driver + identity (core)

  • DualShock 4 buttons/sticks/triggers over WebHID (real DS4 + GameSir DS4-mode clones).
  • Switch Pro buttons/sticks/triggers (report 0x30) + Capture button (synthetic slot 17) + USB/BT connection-type detection (0x80 handshake report).
  • Steam Controller back paddles (slots 18-21), grips, quick-access; one lizard-mode owner per Puck.
  • Per-unit serial/MAC identity; vid:pid disambiguation in identifyFromGamepadId; ControllerDriver.needsSiblingFanout.

Robustness

  • Phantom-handle eviction (stale grants that never stream a report).
  • Stalled-stream recovery (a SELECTED handle that freezes mid-session → driver re-init).
  • Steam Puck fan-out designates the interface actually emitting STATE reports.

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

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
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

🔎 PR #102 preview — torn down

This PR is closed; its web preview and desktop prerelease have been removed.

petegordon and others added 3 commits July 4, 2026 23:23
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
@petegordon petegordon changed the title Seat-select navigator overlay view (+ Steam Controller Puck WebHID churn) Seat-select navigator overlay view + WebHID hardening Jul 5, 2026
petegordon and others added 13 commits July 5, 2026 09:31
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 petegordon changed the title Seat-select navigator overlay view + WebHID hardening WebHID-first controller support: seat-select lobby + overlay picker + single-owner pool Jul 6, 2026
@petegordon petegordon merged commit d398ad4 into main Jul 6, 2026
7 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant