Skip to content

Fix #30: full reconnect recovery for dual-path controllers (GameSir Super Nova)#31

Merged
petegordon merged 3 commits into
mainfrom
fix-hid-reconnect-bind
Jun 7, 2026
Merged

Fix #30: full reconnect recovery for dual-path controllers (GameSir Super Nova)#31
petegordon merged 3 commits into
mainfrom
fix-hid-reconnect-bind

Conversation

@petegordon

@petegordon petegordon commented Jun 7, 2026

Copy link
Copy Markdown
Member

Fixes the GameSir Super Nova (BT, spoofs Sony DS4; buttons/sticks via the Gamepad API, gyro via WebHID) reconnect lifecycle — both states of #30. The Super Nova auto-disconnects on its charger and re-pairs over BT on pickup, so this path is hit constantly.

State 1 — "thinks it has gyro but the data isn't used" (hot-plug / late HID)

On a reconnect (or any hot-plug after launch), the Gamepad-API pad re-claims the slot before the async WebHID connect re-pools the device. _attachMatchingPoolEntry only binds at claim time and never retries; the HID-pool claim loop that could re-bind is gated on a fresh button press the IMU-only DS4 HID interface never produces. Result: claimed slot, working buttons, slot._hidEntry === null → gyro toggle reports capable (static registry flag) but no fusion is fed.

Confirmed by the maintainer in-game: picking the controller up before npm run start (cold-plug → device pooled before claim) works; after start (hot-plug → device pooled after claim) does not.

Fix: a per-frame reconciliation pass binds any pool entry to an already-claimed-but-unbound slot it matches (vid:pid or productName), independent of button activity. Binding-only — never creates a slot.

State 2 — neither buttons nor gyro after dock/undock (orphan dead-end)

On dock, the HID disconnect detaches the gyro entry and the vanished Gamepad pad makes ingestFrame call slot.orphan(). But nothing ever transitions a slot out of orphan (despite the documented empty → claimed → (orphan) → empty): the empty-claim loop only targets empty, the reconciliation pass only targets claimed. So the re-paired controller recovers nothing.

Confirmed from in-game DevTools: P1 HID detached on dock, Controller connected on undock, then no re-claim.

Fix: a reconnect-recovery pass reclaims an orphaned slot by the same controller (keeping its player number) via whichever signal returns first — its Gamepad pad re-enumerates (sticky match by stable id) or its WebHID handle re-pools (match by vid:pid/name, restoring gyro immediately). An adopt pass folds a returning Gamepad pad into a slot that recovered HID-first, so one physical controller is never split across two slots.

Implementation

  • _entryMatchesSlot (extracted) + _findClaimedUnboundSlotForEntry + _findReturningPadForSlot.
  • Reconciliation pass, reconnect-recovery pass, and adopt pass in ingestFrame.

Tests

packages/core/test/manager-hid-reconcile.test.js (6 tests): late IMU-only bind; non-matching slot rejected; productName fallback; sticky orphan re-claim by id (incl. new pad index, no second slot); gamepad+pooled-HID one-frame gyro restore; HID-only recovery then pad adoption. Full suite on this branch: 46 pass.

Closes #30.

🤖 Generated with Claude Code

petegordon and others added 2 commits June 7, 2026 12:26
A dual-path controller (GameSir Super Nova: buttons/sticks via the
Gamepad API, gyro via WebHID, both 054c:09cc) binds its HID entry to a
slot via _attachMatchingPoolEntry, which only runs at claim time. On a
reconnect — the Super Nova auto-disconnects on its charger and re-pairs
over BT on pickup, so this fires constantly — the Gamepad-API pad
re-claims the slot before the async WebHID connect re-pools the device,
so the claim-time bind finds an empty pool and never retries. The
HID-pool claim loop that could re-bind is gated on a fresh button press,
which the IMU-only DS4 HID interface never produces. Result: working
buttons, dead gyro toggle that can't be enabled.

Add a per-frame reconciliation pass in ingestFrame that binds any pool
entry to an already-claimed-but-unbound slot it matches (vid:pid or
productName), independent of button activity. It's binding-only and
never creates a slot, so it's safe to run every frame. Extract the
slot-match into _findClaimedUnboundSlotForEntry (also DRY-ing the
existing HID-pool dedupe block).

Adds test/manager-hid-reconcile.test.js. Full core suite: 47 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tate 2)

An orphaned slot was a dead end — slot.orphan() set state='orphan' but
nothing ever transitioned it back, despite the manager's own
"empty → claimed → (orphan) → empty" contract. So when a controller
dropped (GameSir Super Nova docking on its charger) and re-paired, the
slot stayed orphaned: the empty-claim loop only targets 'empty' and the
reconciliation pass only targets 'claimed', so neither buttons nor gyro
recovered. That's #30 State 2 (confirmed from in-game DevTools: "P1 HID
detached" on dock, HID re-pools on undock, but no re-claim follows).

Add a reconnect-recovery pass in ingestFrame that reclaims an orphaned
slot by the SAME controller (keeping its player number) via whichever
signal returns first:
  1. its Gamepad-API pad re-enumerates (sticky match by stable id), or
  2. its WebHID handle re-pools (match by vid:pid / productName) —
     restores gyro immediately.
Plus an adopt pass: a returning Gamepad pad is folded into a slot that
recovered HID-first (gamepadIndex null) so one physical controller is
never split across two slots.

Helpers: _entryMatchesSlot (extracted, now reused by
_findClaimedUnboundSlotForEntry) and _findReturningPadForSlot.

Adds 3 orphan-recovery tests. Full suite on this branch: 46 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@petegordon petegordon changed the title Fix #30: re-bind late/reconnected HID entries to already-claimed slots Fix #30: full reconnect recovery for dual-path controllers (GameSir Super Nova) Jun 7, 2026
The manager's slot/claim/reconnect logic doesn't need `three`, but it
statically imported SensorFusion (→ `three`) at module load. The new
headless manager tests are the first to import the manager, so CI — which
runs `node --test` with no `npm install` (core declares zero deps) — broke
with "Cannot find package 'three'".

SensorFusion is constructed in exactly one place, the async poolDevice().
Import it dynamically there and inject the instance into HidEntry. Now
importing the manager for its pure logic pulls in no `three`; the runtime
path (poolDevice in browser/Electron) loads it on demand, unchanged.

Verified: the 6 manager tests pass with a loader hook that makes `three`
unresolvable. Full suite still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@petegordon petegordon merged commit dca61e5 into main Jun 7, 2026
1 check passed
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.

GameSir Super Nova: gyro (and sometimes buttons) not recognized after BT reconnect

1 participant