Steam Controller: switch IMU to raw gyro/accel — all 3 axes working#12
Merged
Merged
Conversation
Replaces the quaternion-output path landed in #11 with the standard rate-based encoding used by DualSense. Per-axis Test Report variance analysis revealed the actual 2026 firmware layout differs from what SteamlessController documents: bytes 29-32 = uint32 LE timestamp (~1 MHz clock) bytes 33-38 = 3-axis accelerometer (int16 LE, ±2g full scale) bytes 39-44 = 3-axis gyroscope (int16 LE, ±2000 dps) bytes 45+ = always-zero padding SteamlessController claimed a 4-int16 quaternion at data[31-38] but that overlaps the timestamp on this firmware. With the corrected layout, both sensors look exactly like a DualSense: - gyro: 0 ± 0.2 dps across all axes at rest (perfect zero-bias) - accel: gravity ≈ +1g on body-Z when flat face-up So we flow them straight into the existing SensorFusion pipeline — calibration, drift correction, orientation integration, the L3+R3 recalibrate combo all reuse the proven DualSense code path. No quaternion fast-path, no setSteamQuatTransform DevTools switcher, no reference-quaternion capture. Body-to-visualizer frame remap is in the driver: - Swap Y ↔ Z on both gyro and accel: moves gravity from body-Z to body-Y to match Three.js's "Y up" convention used by DualSense. - Negate Z on both: flips roll direction to match visualizer roll. Field-verified on a 2026 Steam Controller — pitch, roll, AND yaw all track physical motion correctly. The IMU yaw signal is fully usable once read from the right bytes (~34 dps stddev during yaw step, same magnitude as pitch/roll). Known minor issue: ~6-7° residual pitch drift settling on initial calibration. Acceptable for now; can be tuned later via the SensorFusion calibration thresholds. App.js cleanup removes the quaternion-direct scaffolding from #11: - _steamRawQuat / _steamDelta / _steamRefQuat / recalibrateSteamReference - STEAM_QUAT_TRANSFORMS registry + window.setSteamQuatTransform - parsed.orientation fast-path - calibration-skip guard (we DO calibrate now, same as DualSense) Profile change: gyroTransform stays identity (driver does the remap internally — same pattern as Switch Pro). Comment clarifies the field is informational on this profile. Closes the IMU-encoding ambiguity left open in #11.
3 tasks
This was referenced May 24, 2026
petegordon
added a commit
that referenced
this pull request
Jun 7, 2026
Confirmed from hardware (both DS4s over BT): a real Sony DualShock 4 has MAC OUI 90:fb:a6 (Sony), while a GameSir Super Nova spoofing the same Sony VID 054c has OUI a0:5a:5e (GameSir). analyzeIdentity() cross-checks the claimed USB vendor against the MAC's OUI and returns genuine / clone / unverified / no-mac / no-serial. Seeded OUI table with confirmed entries. Honestly handles non-MAC serials (Steam "FXB99…" product serial, Xbox 360 XInput slot "01"/"02") and USB DS4 (no serial). +2 tests; core 70. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
petegordon
added a commit
that referenced
this pull request
Jun 7, 2026
…foundation) (#34) * Add ControllerInventory core module + HID fingerprint probes (#241 foundation) The data model for the "all controllers seen" table (#241): a headless, dependency-free registry of every controller observed, with identity, capabilities, and connection lifecycle. No `three`/DOM, so it stays out of the three import graph and runs under CI's dependency-free node:test. packages/core/src/controller-inventory.js — ControllerInventory: - Identity, best available wins: serialNumber (per-unit) > vid:pid (merges identicals) > productName. Folds WebHID + Gamepad-API sightings of one physical pad into a single record (re-keys a vid:pid record to per-unit when a serial later arrives); keeps two identical-but-distinct serials separate; leaves an un-correlatable serial-less sighting honest. - Lifecycle: firstSeen / lastConnected / lastDisconnected / connected / connectCount, via an injectable clock. - capabilitiesFor(): gyro/accel/touchpad + trackpadCount + haptics + lightbar from the dictionary. - toJSON()/loadJSON() persistence; restored records start disconnected. devices.js — added trackpadCount + haptics{count,type} to every entry (per-entry, since DualSense's adaptive triggers differ from a DS4 sharing PS_FEATURES). index.js/package.json export the module + subpath. tools/hid-probe/ (WebHID feature-report reader) and tools/hid-serial-dump.cjs (node-hid serial dumper) — the diagnostics that established the identity model: Chromium blocklists the MAC reports (DS4 0x12 / DualSense 0x09 → NotAllowedError) so the browser is VID:PID-only, but the OS HID layer (node-hid, and Electron's main-process HID events) exposes a real per-unit serial/MAC — even for a GameSir Super Nova. So per-unit identity is a desktop(Electron) capability; the web overlay merges by vid:pid. +11 tests. Core suite: 67 pass. UI (overlay table) is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add live inventory preview harness (tools/inventory-preview) A one-file page that drives the real ControllerInventory off the browser's WebHID + Gamepad-API events and renders the table, so the core module can be exercised before the production overlay UI exists. History persists to localStorage. Browser limit (VID:PID, no serial) is stated in the UI. Run: npm run inventory-preview → open /tools/inventory-preview/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Convert hid-serial-dump to ESM (.mjs) to match the codebase The product code is ESM (packages/core etc. set "type":"module"); the .cjs outlier was only chosen because node-hid is CommonJS. Use a dynamic import + default interop in an .mjs instead (still fails gracefully when node-hid isn't installed). Update the hid-dump npm script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add macOui/formatSerial helpers for MAC-based clone detection macOui() extracts the OUI (vendor block) from a 12-hex Bluetooth MAC serial; formatSerial() pretty-prints it. Strict MAC matching (exactly 12 hex, or colon/hyphen separated) so a product serial like the Steam Controller's "FXB9960202571" isn't mistaken for a MAC. Foundation for flagging a pad that claims one vendor's USB VID but carries another vendor's MAC OUI (GameSir-Super-Nova-as-DualShock). +1 test; core 68. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add analyzeIdentity: detect spoofed controllers by MAC OUI (#12) Confirmed from hardware (both DS4s over BT): a real Sony DualShock 4 has MAC OUI 90:fb:a6 (Sony), while a GameSir Super Nova spoofing the same Sony VID 054c has OUI a0:5a:5e (GameSir). analyzeIdentity() cross-checks the claimed USB vendor against the MAC's OUI and returns genuine / clone / unverified / no-mac / no-serial. Seeded OUI table with confirmed entries. Honestly handles non-MAC serials (Steam "FXB99…" product serial, Xbox 360 XInput slot "01"/"02") and USB DS4 (no serial). +2 tests; core 70. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add Electron Controller Inventory window (serials via main-process HID events) The desktop inventory table (#241). Main process captures serialNumber from its HID device events (select-hid-device deviceList + hid-device-added/ removed) — which expose the serial the renderer's blocklisted WebHID can't — and pushes the controller map to a new inventory window. No native module (avoids the node-hid/Electron-ABI rebuild); inventory degrades to Gamepad API if serials don't come through. Logs each device's serial so we can confirm Electron surfaces it. - electron/main.js: hidControllers map + upsert/broadcast from session HID events; openInventoryWindow(); ipc open-inventory-window / list-hid- controllers; tray "Controller Inventory…" item. - electron/preload.js: openInventoryWindow / listHidControllers / onHidControllersSnapshot bridge. - src/inventory.html + src/js/inventory.js: table (Genuine?/Serial/OUI/ Transport/lifecycle/caps) driven by ControllerInventory; Scan button triggers enumeration; localStorage persistence; Gamepad-API merge. Open via the tray menu after `npm start`. analyzeIdentity flags the Super Nova as a clone vs a real DS4 by MAC OUI. Core suite still 70. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Catalogue GameSir Cyclone 2 OUI (d0:56:80) for clone detection Confirmed from hardware: a GameSir Cyclone 2 in DS4 v2 mode (advertises Sony 054c:09cc) has Bluetooth MAC OUI d0:56:80 — a different GameSir block than the Super Nova's a0:5a:5e. Added so it's flagged as a clone instead of "unverified". GameSir uses multiple OUIs; catalogue each as captured. +test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Catalogue real DualSense OUI (50:ee:32) as Sony Confirmed from hardware: a genuine DualSense (054c:0ce6) has MAC OUI 50:ee:32 → now labelled genuine instead of unverified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drop OUI clone-detection; keep MAC as per-unit identity Evaluation showed OUI→vendor is unreliable for this: genuine PlayStation controllers carry a Foxconn (Hon Hai) OUI — NOT Sony's (90:fb:a6, 50:ee:32 both resolve to Hon Hai in the 88k IEEE/Wireshark/nmap master DB) — and the GameSir clone OUIs (a0:5a:5e, d0:56:80) aren't in any registry at all. So an allowlist would false-flag real pads and a denylist is a treadmill. Removed analyzeIdentity + the OUI/VID vendor tables and the Genuine?/OUI columns. Kept the solid part: the full MAC is the per-unit identity (distinguishes identical pads over Bluetooth). macOui/formatSerial retained; added isMacSerial (used for BT-vs-USB transport inference). If a vendor lookup is ever wanted it can be injected — design + the parked "OUI web API" idea are in the controller-identity memo. Core suite 69. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Inventory: make Scan an authoritative refresh of connected state Scan's select-hid-device deviceList is the set of currently-present HID devices, so on each Scan we now also mark any known controller NOT in that list as disconnected. This gives an accurate on-demand refresh of connected/offline + Last disconnected without a native module — chosen over node-hid polling to keep the no-dependency, browser/web-compatible path (node-hid auto-polling remains a possible future upgrade). Button relabeled "Scan / Refresh". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- 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.
Follow-up to #11. Pivots the Steam Controller's IMU pipeline from the quaternion-output path that #11 shipped to the standard rate-based DualSense path, and all 3 axes now work correctly.
What changed
Per-axis Test Report variance analysis revealed the actual 2026 firmware IMU layout differs from what SteamlessController documents:
The numbers at rest match a DualSense exactly — gyro is 0 ± 0.2 dps zero-bias across all axes, and accel reads ~1g of gravity on body-Z. So they flow straight into the existing SensorFusion pipeline (calibration, drift correction, L3+R3 recalibrate, integrated orientation) with zero special-casing.
Driver-side remap
Body-to-visualizer frame: swap Y↔Z and negate Z on both gyro and accel. The swap moves gravity from body-Z to body-Y (Three.js "Y up" convention, matches DualSense). The negation aligns roll direction. Same pattern Switch Pro uses for its frame remap.
App.js cleanup
Removes everything from #11 that no longer applies:
_steamRawQuat/_steamDelta/_steamRefQuatstaterecalibrateSteamReference+setSteamQuatTransformDevTools APISTEAM_QUAT_TRANSFORMSregistryparsed.orientationfast-pathRoughly -106 lines from app.js, +30 driver lines (mostly comments explaining the remap), +5 profile lines.
Field verification
Physical pitch / roll / yaw all rotate the on-screen model on the correct axis with correct sign. Calibration completes in ~1 second. L3+R3 recalibrates.
Known minor issue
~6-7° residual pitch drift settling on initial calibration — accel-based gravity correction nudging the orientation to match the measured gravity vector. Acceptable for now; can be tuned later via SensorFusion calibration thresholds.
Commit
28ef7f6switch IMU to raw gyro/accel — all 3 axes workTest plan
Refs #8.