Skip to content

Device dictionary + Test Report wizard + DS4 IMU fix + auto-analyzer#1

Merged
petegordon merged 2 commits into
mainfrom
feat/device-dictionary-and-wizard
May 24, 2026
Merged

Device dictionary + Test Report wizard + DS4 IMU fix + auto-analyzer#1
petegordon merged 2 commits into
mainfrom
feat/device-dictionary-and-wizard

Conversation

@petegordon

Copy link
Copy Markdown
Member

Summary

Turns the lab from "ask Claude to analyze hex dumps" into a self-service workflow for adding new controllers. End-to-end pipeline:

plug in controller → spoof picker tells us which one it actually is → walk through 9 scripted capture steps → save JSON → run analyzer → paste candidate entry into devices.js → verify

  • Device dictionary (packages/core/src/devices.js) becomes the single source of truth for known controllers — decouples physical identity (vid:pid, name, capabilities, features) from protocol implementations (driver classes). Adding a new pad that speaks an existing protocol is one entry.
  • DS4 IMU fix in DualSenseDriver — mode-branched USB parser uses offsets 12/18 for DS4 (was incorrectly using DualSense's 15/21). Empirically validated against GameSir Super Nova AND Cyclone 2 in DS4 mode; should also fix any real Sony DS4 (latent bug — the prior offsets were always DualSense-tuned).
  • Test Report wizard in the overlay's settings panel — 9 scripted steps with 3-2-1 countdown, live capture counter, spoof picker for multi-match vid:pids, alias/note naming prompt, native Electron save dialog. JSON schema controller-test-report/v1.
  • Auto-analyzer (tools/analyze-report.js) — reads a wizard JSON, identifies IMU offsets by scoring at-rest accel magnitude vs 1g target, maps gyro axes from pitch/roll/yaw steps, emits a pasteable DEVICES entry.
  • GameSir Super Nova + Cyclone 2 entries registered alongside Sony DS4 v2 (all three spoof 054c:09cc). Super Nova exposes independent back-paddle HID bits; Cyclone 2's paddles rebind to A/B at the firmware level (backPaddles: false).
  • Steam Controller 2026 stub driver reserves the slot for future Steam Input work.

Worked example with real byte values is in docs/ADDING-A-CONTROLLER.md.

Test plan

  • npm install at repo root completes cleanly.
  • npm --workspace @usersfirst/overlay run start launches; existing DualSense (DS5) detection still works (regression check on the DS5 path of the IMU branch).
  • With GameSir Super Nova in DS4 mode plugged in: 3D model holds still at rest, tilts in correct direction, calibration stddev is single digits (was 18000+ pre-fix).
  • Open settings → CAPTURE HID REPORT → spoof picker shows 3 options for 054c:09cc (Sony DS4 v2 / Super Nova / Cyclone 2 / Unknown).
  • Wizard runs all 9 steps with 3-2-1 countdowns and live counter.
  • Save dialog accepts a path; JSON file written with alias, userNote, pickedEntry, all step reports.
  • node tools/analyze-report.js path/to/report.json produces a sensible candidate entry.
  • Cyclone 2 capture: wizard's back-paddles step is skipped because features.backPaddles === false on that entry.

🤖 Generated with Claude Code

petegordon and others added 2 commits May 23, 2026 20:19
Turns the lab from "ask Claude to analyze hex dumps" into a self-service
workflow for adding new controllers. End-to-end pipeline:

  plug in controller → pick which one it actually is (spoof picker) →
  walk through 9 scripted capture steps → save JSON → run analyzer →
  paste the candidate entry into devices.js → verify

What's in the box:

CORE — DEVICES dictionary as source of truth
  - packages/core/src/devices.js — DEVICES + PROTOCOLS map, decouples
    physical-device identity (vid:pid, name, capabilities, features)
    from protocol implementations (driver classes). Adding a new pad
    that speaks an existing protocol is one entry; new protocols
    still need a driver class.
  - Per-entry `features` block (faceButtons, systemButtons, triggers,
    sticks, dpad, gyro, accel, touchpad, backPaddles, ...) drives the
    Test Report wizard's step filter — Xbox skips IMU/touchpad steps,
    Switch Pro skips touchpad, etc.
  - `spoofs: { of, vendorId, productId }` on entries that advertise
    another's USB identity (GameSir Super Nova spoofs Sony DS4 v2).
    Multiple entries may share a vid:pid; the spoof picker surfaces all
    of them at capture time so the user disambiguates.
  - controller-registry.js gains getAllEntries() for the picker; getEntry
    still returns the primary (first registered) match for backwards
    compat. Driver classes lose static identity (was duplicating the
    dictionary) and accept the entry as a third constructor arg —
    instances expose `driver.entry` for downstream consumers.
  - SteamControllerDriver stub for the eventual Steam Controller 2026.

DS4 IMU FIX in DualSenseDriver
  - parseReport USB branch now keys IMU offsets off entry.mode:
    'ds4' uses gyroOffset=12/accelOffset=18, 'ds5' uses 15/21 (the
    existing layout).
  - Empirically tuned and validated against GameSir Super Nova AND
    GameSir Cyclone 2 (both spoof Sony 054c:09cc in DS4 mode; both
    use the offset-12 IMU layout). Same fix should light up any real
    Sony DS4 too — the prior offset 15 was DualSense-tuned and was
    always wrong for the DS4 family.

TEST REPORT WIZARD — "Capture HID Report" button in single-overlay
  - apps/overlay/src/js/test-report.js — 9 scripted prompts (at-rest,
    pitch/roll/yaw, face buttons, system buttons, triggers+shoulders,
    sticks+dpad, touchpad, back paddles). Each step declares `requires`
    of the controller features it tests; stepsForEntry() filters.
  - Wizard UI: spoof picker (when multi-match) → step prompts with
    3-2-1 countdown + live "Xs left, N reports captured" → alias/note
    naming prompt → Electron save dialog. JSON schema embeds the
    user's picked entry + alias + note alongside every captured report.
  - Schema "controller-test-report/v1" — stable enough that the
    analyzer can rely on it; bumps if breaking changes happen.

AUTO-ANALYZER — tools/analyze-report.js
  - CLI: `node tools/analyze-report.js path/to/report.json`
  - Searches all plausible IMU offsets; scores each by how close the
    at-rest accel magnitude lands to 8192 (1g at standard ±4g/16-bit
    scale) and how close the at-rest gyro is to zero.
  - Identifies which gyro axis activated during pitch/roll/yaw, plus
    which bytes change during button/trigger steps.
  - Emits a candidate DEVICES entry ready to paste, plus warnings for
    skipped steps and suspicious magnitudes.

GAMESIR ENTRIES landed in DEVICES
  - GameSir Super Nova (DS4 mode) — spoofs Sony DS4 v2, backPaddles=true
    (independent HID bits).
  - GameSir Cyclone 2 (DS4 mode) — spoofs Sony DS4 v2, backPaddles=false
    (paddles rebind to A/B at firmware level, no independent HID).

DOCS
  - docs/ADDING-A-CONTROLLER.md — end-to-end workflow with the GameSir
    Super Nova capture as the worked example.
  - packages/core/README.md — DEVICES dictionary shape + add-a-pad
    instructions.

Bonus fixes:
  - manager.js controllerTypeHint now uses entry.protocol directly
    (matches visualizer profile keys) instead of mangling driver names.
  - manager.js accel-verified log line reads driver.entry?.name.
  - Overlay callsites use getEntry/driver.entry instead of the removed
    driver-class static identity.
  - Electron preload exposes window.electronAPI.saveTestReport, main
    handles the save-test-report IPC channel with dialog.showSaveDialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decouples the visualizer's 3D model selection from the protocol family
so clones (GameSir Super Nova, Cyclone 2, ...) can ship their own GLB
once a model is sourced — without renaming the protocol or forking the
driver class.

Single optional field on each DEVICES entry:

  controllerProfile?: string — visualizer PROFILES key. Defaults to
                               `entry.protocol` when missing, so every
                               existing entry continues to load the
                               same GLB it does today (zero behavior
                               change). Override per-entry once a
                               controller-specific PROFILES entry +
                               GLB exist.

Plumbing changes:
  - controller-registry.js: identifyFromGamepadId() now includes
    `controllerProfile: entry.controllerProfile || entry.protocol`
    in its returned shape.
  - manager.js: controllerTypeHint built from controllerProfile rather
    than raw protocol (with fallback for callers that don't populate it).
  - app.js: new pickControllerProfile(gamepadId) helper consults the
    registry first, falls back to detectControllerType (the visualizer's
    own id-pattern sniff) when no dictionary match. detectInitialController
    and switchController both use it.
  - multi-app.js: _renderClaimed() consults the registry the same way.

Docs:
  - packages/core/README.md — entry-shape table gains controllerProfile;
    explains the per-clone-visualizer story.
  - docs/ADDING-A-CONTROLLER.md — new "Adding a custom 3D visualizer"
    section covering GLB sourcing (photogrammetry / stock / Blender),
    mesh-naming conventions, PROFILES entry, and wiring the DEVICES
    entry's controllerProfile field.

No GLBs land in this commit — the path stays open for when Super Nova
or Cyclone 2 visualizers are sourced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@petegordon petegordon merged commit 8c04564 into main May 24, 2026
petegordon added a commit that referenced this pull request Jul 8, 2026
* Support multiple Steam Controllers on one Puck (per-unit interfaces)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* Wire multiple Steam Controllers into start:multi (WebHID-first boot pooling)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* WebHID-first: remove the Gamepad-API gate from autoPoolApprovedHid

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* Keep idle Steam Puck interfaces pooled (fix power-on after launch)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* Redesign multi app: dynamic PLAYER n roster + AVAILABLE list

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* Multi app: make the controller List a detached popout window

The List is now an independent always-on-top window instead of an inline
panel, so it can be moved/placed separately from the multi overlay (e.g. on a
second monitor or off the capture area).

- New HUD kind 'multi-controllers' (main.js) → multi-controllers-window.html
  + multi-controllers-window.js, reusing the generic detached-HUD plumbing
  (open-hud-window / hud-state relay) and shared hud-window-chrome.js for
  drag/close — same pattern as the single overlay's Controllers window.
- The List button now opens the popout; multi-app forwards rosterRows() each
  frame via sendHudState('multi-controllers', …) (throttled ~5Hz; main drops
  it when the window is closed). Read-only: players join by pressing a button.
- Removed the inline #controller-list panel + CSS from multi.html.

Renders AVAILABLE (pooled streaming controllers) vs PLAYER n (claimed), with
live in-use dots and #1/#2 disambiguation, matching the inline version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* Multi app: add close button + fix panel deformation on grid reflow

- Close button: a red × in the top-right corner of the frameless multi window
  quits the app (window.electronAPI.quit → app.quit). List/Debug shift left to
  make room.
- Reflow deformation: when a player joins/leaves, the auto-fit grid resizes
  every other panel's canvas (CSS) but not its WebGL drawing buffer, so the 3D
  model stretched until the window was manually nudged. Each SlotView now holds
  a ResizeObserver on its canvas and resizes the renderer the instant the panel
  reflows (setSize(w,h,false) leaves CSS size untouched, so no observer loop);
  disconnected on dispose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* Lobby: close button + raise player cap; share MAX_CONTROLLERS from core

Two lobby fixes plus a small core refactor:

- Fifth controller couldn't go ACTIVE: the lobby hardcoded 4 manager slots
  (P1-P4), so only 4 controllers could be recognized. Modes still cap SEATS at
  4 (versus 2v2), but a 5th+ controller now becomes ACTIVE and waits in the
  lounge for a seat to open (the lounge + takeSeat already supported this).
- Added a close (×) button in the lobby's top-right pairbar → app.quit.
- Hoisted the "16" cap into core as `MAX_CONTROLLERS` + a `playerSlotIds(n)`
  helper (exported from the barrel), and switched both the multi app and lobby
  to use them instead of each hardcoding the number.

131 core tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* Lobby: widen controller panel, hide idle Puck siblings; centralize the filter

Addresses the lobby list showing the Steam Puck's idle receiver interfaces as
phantom "AVAILABLE" rows (a side effect of keeping them pooled for power-on),
and the "(via Puck)" name getting truncated.

- Widened the lobby #ctrl-panel 300→380px so "Steam Controller 2026 (via Puck)"
  fits without ellipsis.
- Centralized the "only show streaming fan-out interfaces" filter into core as
  `isPresentableEntry(entry)` + `ControllerManager.presentablePoolEntries()`
  (barrel-exported), replacing the predicate that was duplicated in the single
  overlay list, multi roster, lobby list, and lobby count. The driver still
  declares the `needsSiblingFanout` trait; core owns combining it with the
  entry's streaming state (the entry lives in the manager).
- All four call sites now use the shared helper; the lobby count matches the
  filtered list. +4 core tests. 135 core tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

* Pair button: environment-aware, no redundant requestDevice stall

Clicking "Pair controller" when everything was already paired ran a full
navigator.hid.requestDevice() scan — which in Electron enumerates every system
HID device and briefly blocks the app — just to conclude "nothing new".

- core: connectHidForSlot(slotId, { prompt = true }) gates the requestDevice
  fallback. prompt:false runs only the cheap getDevices-pool phase (no scan).
  Default true keeps existing callers unchanged. +3 tests.
- lobby pairController is now environment-aware:
    • Browser — requestDevice IS the only way to grant access → always prompt.
    • Electron — pool an already-approved device cheaply; only run the blocking
      scan when nothing is paired yet (first-time setup) or the user clicks
      again to force it ("✓ N connected — click again to scan for a new one").
- single overlay: connectControllerGyro(allowRequest=true); the boot
  auto-connect timer now passes false so a gesture-less requestDevice (which
  just throws, and in Electron would run the blocking scan) is never attempted
  at boot. The Connect button and gyro toggle keep prompting. (This flow
  already avoided the redundant stall by matching any granted gyro device
  first, so no behavior change for the user's Connect click.)

138 core tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qnt4svMm6b8zkWfSk75vW

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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