Fix #30: full reconnect recovery for dual-path controllers (GameSir Super Nova)#31
Merged
Conversation
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>
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>
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.
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
connectre-pools the device._attachMatchingPoolEntryonly 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
ingestFramecallslot.orphan(). But nothing ever transitions a slot out oforphan(despite the documentedempty → claimed → (orphan) → empty): the empty-claim loop only targetsempty, the reconciliation pass only targetsclaimed. So the re-paired controller recovers nothing.Confirmed from in-game DevTools:
P1 HID detachedon dock,Controller connectedon 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.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