Skip to content

Steam Controller: switch IMU to raw gyro/accel — all 3 axes working#12

Merged
petegordon merged 1 commit into
mainfrom
feat/steam-controller-raw-gyro-accel
May 24, 2026
Merged

Steam Controller: switch IMU to raw gyro/accel — all 3 axes working#12
petegordon merged 1 commit into
mainfrom
feat/steam-controller-raw-gyro-accel

Conversation

@petegordon

Copy link
Copy Markdown
Member

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:

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

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 / _steamRefQuat state
  • recalibrateSteamReference + setSteamQuatTransform DevTools API
  • 16-mode STEAM_QUAT_TRANSFORMS registry
  • parsed.orientation fast-path
  • Calibration-skip guard (we DO calibrate now, same as DualSense)

Roughly -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

  • 28ef7f6 switch IMU to raw gyro/accel — all 3 axes work

Test plan

Refs #8.

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.
@petegordon petegordon merged commit d260462 into main 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>
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