Skip to content

feat: Steam Controller over USB Direct (boot-interface ranking, device restore on release) - #154

Merged
emir-hasanbegovic merged 5 commits into
mainfrom
feat/steam-controller-usb-direct
Aug 15, 2026
Merged

feat: Steam Controller over USB Direct (boot-interface ranking, device restore on release)#154
emir-hasanbegovic merged 5 commits into
mainfrom
feat/steam-controller-usb-direct

Conversation

@emir-hasanbegovic

@emir-hasanbegovic emir-hasanbegovic commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Adds Steam Controller support (wired 28DE:1102 and the wireless dongle 28DE:1142) to USB Direct mode, along with the two pieces of shared machinery that supporting it needed: generic boot-interface ranking, and a device-restore hook that runs when a claim is released.

Why this pad only works on Direct

The Steam Controller ships in stand-alone ("lizard") mode, emulating a keyboard and a mouse. Its actual game interface is vendor-defined HID with no gamepad usages, so Android never enumerates it as a gamepad and PhysicalGamepadRegistry has nothing to bind.

That makes this different from every other model in the catalog. For DualSense or Switch Pro, Direct is an upgrade over the framework path. Here it is the difference between the controller working and not existing on the platform at all.

Commits

28ea71c feat: Steam Controller over USB Direct
6e53f32 build: parallel Tooling Model Builders for faster IDE sync
39ea725 fix: hand the Steam Controller's right pad back as a mouse on teardown
b92dfe4 fix: Steam Controller release, unplug, and dongle-drop lifecycle
a3b7957 feat: keep the foreground service up while a Direct claim is held

What is included

1. Steam Controller decode

Parser::STEAM_CONTROLLER decodes the 48-byte state packet: buttons, dpad, analog triggers (Valve's 26000 full scale, so the throw saturates before the raw rail), the left stick, the right trackpad as a right stick, and the IMU.

Two decode details worth review:

  • The left stick and left trackpad share one pair of axes. STEAM_LEFTPAD_FINGERDOWN selects which, and STEAM_LEFTPAD_AND_JOYSTICK means frames alternate, so the stick value is held in ParserState across pad frames. Matches FormatStatePacketUntilGyro, including the firmware quirk where a stick click arrives as a left-pad click while the pad is idle.
  • The right pad carries SDL's 15° shell rotation but not its extra +1000 offset, which is fine for a touch surface but would park a stick off centre. It recentres on lift, since pad coordinates are meaningless with no finger down.

The IMU block self-gates: an all-zero accel and gyro read means the sensor enable never took, and publishing it would stream a dead sensor, so motionValid stays false.

2. Stand-alone mode off at attach, restored on release

Switched off at attach (ID_CLEAR_DIGITAL_MAPPINGS, then one ID_SET_SETTINGS_VALUES carrying both trackpad modes and the IMU enable) through a new runTeardown hook that runs on all three exit paths, including both attachDevice bail-outs where a partly-applied init has already changed the device.

This is the first parser in the codebase whose init persistently reconfigures the device rather than kicking it into a mode, so the restore path carries more weight than usual: skipping it hands a user back a controller that no longer works as a desktop mouse, from an operation that visibly did nothing. Restore therefore mirrors SDL's CloseSteamController rather than hid-steam, which stops one packet earlier: after ID_LOAD_DEFAULT_SETTINGS it re-sets the right trackpad to absolute mouse by name, because loading the defaults does not reliably bring mouse mode back on its own.

Feature-report writes retry on EPIPE the way SDL and hid-steam both do, since that is the wireless dongle under load rather than a real failure. The retry budget is capped at 25 tries rather than hid-steam's 50: at 20ms apart, 50 would let a wholly unresponsive device spend 2s in init plus 3s in teardown and overrun UsbGamepadManager's 4s TRANSITION_TIMEOUT_MS, turning a clean fallback into a spurious needs-replug. A persistent failure is reported as the existing DirectClaimFailure.InitFailed and the claim is released. It deliberately does not degrade to "streaming works but the pad is also typing into your phone", which nothing in the model represents and the user could not diagnose.

The IMU enable shares a packet with the trackpad-mode writes on purpose: there is no path where the pad goes quiet but motion stays off, which would advertise a sensor that never reports.

runTeardown is a general hook, not a Steam special case. It is a no-op for every family that never changed the device, and it is where a Switch Pro mode restore would go if that is ever wanted.

3. Interface ranking, generalised

gameInterfaceRank scored every HID-class interface alike, which is correct for every model shipped so far because none of them lead with emulated human-interface devices. A pad that does needs the picker to tell them apart: without it, findInterruptInPair keeps the first candidate and the claim lands on interface 0, the keyboard. Classification then falls through to GENERIC_HID_GAMEPAD, whose descriptor parse fails (keyboard input items sit on usage pages 0x07/0x08) and whose fallback decoder gates only on len >= 7, so an 8-byte boot keyboard report would decode "successfully", pinning the sticks to the rail and firing keycode bytes as buttons.

Boot-protocol HID interfaces now sort below everything else. The rule is keyed on bInterfaceSubClass/bInterfaceProtocol, not on Valve's vendor id, so any composite pad benefits. They are deprioritised, not disqualified, so a device offering nothing else is still claimable. Rank constants renumbered accordingly; relative order of XInput, GIP, HID and vendor-fallback is unchanged.

4. Build config (unrelated, bundled here)

org.gradle.tooling.parallel=true lets the IDE build project models concurrently during sync. It overrides org.gradle.parallel in that context only, so task execution is unaffected. No relation to the rest of the PR, carried here rather than spending a separate round trip on a three-line properties change.

What the user sees

  • The pad appears by name (Valve Steam Controller, or (dongle)) in the guided USB setup flow, which lists raw USB devices rather than registry gamepads.
  • Picking Direct claims it, and sticks, triggers, buttons, dpad, the right pad as a right stick, and motion stream to the bound destination.
  • While claimed, it stops acting as a mouse and keyboard on the phone.
  • Switching back to Standard, unplugging, or any claim failure restores stand-alone mode.
  • No new screens. One new notification string (the claim-hold body, translated in all six locales) arrived with the follow-up commits below. For other controllers the only behaviour change is the foreground service also counting their held Direct claims.

Capability surface, all derived from the existing native model queries rather than new plumbing:

Feature State Path
Motion Available parserHasImumodelHasImuPathCapabilities.motion
Rumble Not available parserHasRumble false, nothing advertised
Touchpad Sourced from the phone overlay parserHasTouchpad false → TouchpadRoutingTouchpadSource.PHONE
Auto-claim Never in kImported, so isVerifiedFastLane is false

Changed surface

File What
app/src/main/cpp/usb_parsers.h Parser::STEAM_CONTROLLER, InitKind::STEAM_QUIET, SteamConfig, held-stick state, runTeardown declaration
app/src/main/cpp/usb_parsers.cpp decodeSteamController, buildSteamConfigPacket, sendFeatureReport, runTeardown, two kImported rows, capability predicates
app/src/main/cpp/usb_host.cpp runTeardown on all three exit paths; runInit gains the interface number
.../source/usb/UsbGamepadManager.kt boot-interface deprioritisation in gameInterfaceRank
app/src/test/cpp/usb_parsers_test.cpp 20 native tests
.../source/usb/UsbGamepadManagerTest.kt 2 interface-selection tests
THIRD_PARTY.md SDL hidapi/steam and hid-steam attribution
CHANGELOG.md entry under [Unreleased] / Added
docs/usb-direct-mode-followups.md item 8: status, unverified list, known limitations, acceptance criteria
gradle.properties the Tooling API parallel flag

Not touched: no wire-protocol change (nothing in wire_encoders, no protocolVersion bump, not [wire-coordinated]), no UI layouts, no satellite or host-side work. This is a client-only change and needs no fleet coordination. (The follow-up commits add one notification string per locale and one DI edge, StreamingServiceController/StreamingServiceUsbGamepadManager; the graph stays acyclic and Hilt compiles it.)

Scope: what is deliberately not here

  • Rumble. The pad has no motors, only trackpad voice coils driven by ID_TRIGGER_HAPTIC_PULSE pulse trains; the simple rumble command is Steam Deck firmware only and hid-steam gates force feedback on the Deck quirk.
  • The grip buttons, which have no XUSB equivalent.
  • Streaming either trackpad over MSG_TOUCHPAD, which would double-actuate against the right stick.
  • Bluetooth. The pad speaks Valve's own BLE protocol, not HID over GATT, so it never reaches the framework as a gamepad.

Not verified on hardware

I have no Steam Controller. Protocol facts come from SDL's hidapi/steam headers and hid-steam, attributed in THIRD_PARTY.md. IMU axis signs, right-pad feel, and dongle behaviour are unverified and written up in docs/usb-direct-mode-followups.md item 8, along with two known limitations:

  • The dongle enumerates four pad interfaces, one per paired controller, and the rank tiebreak only ever reaches the first. A pad on another slot fails probeDecodable and falls back to routed, which is safe but reads as "Direct failed".
  • The interface pick assumes the emulated keyboard and mouse declare the HID boot subclass. hid-steam deliberately does not rely on that, distinguishing the real pad by its report descriptor instead. If the assumption is wrong the config packets stall and the claim falls back to routed after roughly a second.

One residual risk that cannot be closed from inside the process: a controller left paired to the dongle stays powered when the dongle is pulled, so an app kill between attach and teardown could leave it mute as a desktop mouse until it sleeps or the Steam button is held. A wired pad self-heals because unplugging powers it down. The follow-up service hold (a3b7957) shrinks the exposure, since a claimed pad no longer rides in an unprotected cached process, but nothing can send a restore at kill time.

Review follow-ups

A scenario audit of what happens to the flipped device-side settings across releases, disconnects, backgrounding, and process death turned up four gaps; b92dfe4 and a3b7957 close them.

  • Every clean release falsely reported failure. The restored pad settles as a keyboard and mouse, which PhysicalGamepadRegistry rightly refuses to track as a gamepad, so FrameworkUp never fired and the FSM's wait timed out into RestoreStuck ("Standard isn't responding") on every single switch back, with NeedsReplug on the stolen-interface claim-failure path. UsbController.frameworkExpected (fed from modelExpectsFrameworkGamepad in the native model table) now settles both paths on Standard immediately for models with no framework gamepad identity.
  • A pad dropping off the dongle latched its last input. ID_CONTROLLER_WIRELESS events were rejected by the decoder, so a pad powering off mid-session kept its last decoded state on the wire, plausibly the held Steam button of the power-off gesture, popping overlays host-side. And a pad powering back on rebooted into stand-alone defaults with no re-init, streaming without motion while its lizard keyboard leaked into the phone. checkWirelessEvent now classifies the events; the poll loop publishes neutral state on disconnect and re-runs the attach init on connect, mirroring hid-steam's reconnect handling.
  • Physical unplug leaked the native device context. The unplug path emitted RemoveSynthetic without ever calling the native detach, stranding the exited poller thread, its dup'd fd, and dispatch state in g_devices, for every Direct family, not just this one. The RemoveSynthetic effect now detaches first; the call is idempotent after a Release.
  • Backgrounding left a claimed pad unprotected. The connectedDevice foreground service stopped on process ON_STOP while the USB claim survived, so a background kill (the one exit with no restore hook) was the likeliest way to strand a quiet-mode pad. The service and its controller now count held Direct claims alongside streaming slots, run in the background while any remain, and exit when the last goes; the notification's Stop action releases held claims (restore included) and its body says what is held when nothing is streaming.

Tests and verification

  • 26 native tests covering classification and init resolution, both config sequences and their bounds, buffer-overrun refusal, every button mapping, trigger scaling and saturation, all three left-axis sharing modes, right-pad rotation and recentring, IMU axis order and scale, the silent-IMU guard, rejection of short, wrong-version and wireless-event packets, wireless connect/disconnect classification (and its malformed and wrong-parser rejections), and the framework-identity table.
  • JVM tests: interface selection (the controller interface wins over a boot keyboard and mouse; a boot-only device is still claimable); FSM coverage of the no-framework-identity release and stolen-claim-failure settles, the never-starts-the-stuck-timer guarantee, the opt-straight-back-into-Direct journey, and totality over both frameworkExpected values; manager-level release flows for both identities, the double-detach the unplug path relies on, and releaseAllDirect; service-controller coverage of claim-alone start, background hold with claims, background stop without, and the foreground re-assert.

Every protocol constant was cross-checked against current upstream (SDL main, Linux master): the 19 button masks, byte offsets for buttons, triggers, axes and IMU, trigger expansion and full scale, pad rotation direction, IMU axis order and both scale factors against this project's wire convention, message and setting ids, trackpad and gyro mode values, and the SET_REPORT framing.

Local gates green: clang-format (22.1.4, matching the CI pin), play-metadata lint, ktlint, detekt, Android lint, JVM unit tests, 318 native tests, assembleDebug, and the JNI library build. Instrumented suite runs in CI.

The pad ships emulating a keyboard and mouse, so Android never saw it as a
gamepad and picking Direct claimed its keyboard interface and streamed decoded
key bytes as stick and button state.

Interface ranking now deprioritises boot-protocol HID interfaces instead of
treating every HID interface alike, so a composite pad that leads with a
keyboard and mouse is claimed on its controller interface. The rule is generic
rather than keyed on Valve's vendor id.

A new parser decodes the state packet: buttons, dpad, analog triggers, the left
stick (which shares its bytes with the left trackpad, selected by the
finger-down bit), the right trackpad as a right stick that recentres on lift,
and the IMU. Attach stops the stand-alone keyboard/mouse emulation and enables
the IMU; teardown puts both back on all three exit paths, including the two
attach bail-outs where a partly-applied init has already changed the device.
Feature-report writes retry on EPIPE the way SDL and hid-steam do, and a
persistent failure is reported as InitFailed rather than quietly streaming a
pad that is also typing into the phone.

Listed as unverified, so Direct stays opt-in and is never auto-claimed. No
rumble: the pad has no motors, and the simple rumble command is Deck-only.
Protocol facts come from SDL and hid-steam; attribution in THIRD_PARTY.md and
the unverified items in docs/usb-direct-mode-followups.md.
org.gradle.tooling.parallel lets the IDE build project models
concurrently during sync. It overrides org.gradle.parallel in that
context only, so task execution is unaffected.
ID_LOAD_DEFAULT_SETTINGS does not by itself restore the right trackpad
to mouse mode. SDL's CloseSteamController follows it with an explicit
right trackpad mode = absolute mouse; hid-steam does not, and that gap
is the one remaining way this teardown could return a pad its owner
cannot use. Append the same packet as a third restore step, with a
per-sequence length so the quiet sequence still stops at two.

Raise the feature-report EPIPE retry budget from 10 tries to 25.
hid-steam allows 50 because the wireless dongle stalls intermittently
under load; 25 keeps a wholly unresponsive device's init and teardown
inside UsbGamepadManager's 4s path-transition timeout.

Record two limitations in the followups doc: the dongle enumerates four
pad interfaces and the rank tiebreak only ever reaches the first, and
the interface pick assumes the emulated keyboard and mouse declare the
HID boot subclass, which hid-steam deliberately does not rely on.
@emir-hasanbegovic emir-hasanbegovic changed the title feat: Steam Controller over USB Direct feat: Steam Controller over USB Direct (boot-interface ranking, device restore on release) Aug 5, 2026
Three gaps around the quiet-mode settings a Direct claim flips on the
pad:

- A release waited for a framework gamepad that never re-enumerates
  (the restored pad settles as keyboard and mouse), so every switch
  back to Standard timed out into a false RestoreStuck banner, and a
  stolen-interface claim failure into a false NeedsReplug. The FSM now
  carries frameworkExpected, fed from the native model table, and
  settles Routed immediately on both paths for models without a
  framework gamepad identity.
- The dongle's ID_CONTROLLER_WIRELESS events were rejected by the
  decoder, so a pad powering off mid-session left its last decoded
  input latched on the wire (plausibly the held Steam button of the
  power-off gesture), and a pad reconnecting after its reboot streamed
  without motion while its lizard keyboard leaked into the phone.
  checkWirelessEvent now classifies the events: disconnect publishes a
  neutral state, connect re-runs the attach init.
- A physical unplug removed the synthetic without detaching the native
  device, stranding the exited poller thread, its dup'd fd, and its
  dispatch state in g_devices for every Direct family. The
  RemoveSynthetic effect now detaches first; the call is idempotent
  after a Release.

Also carries directClaimCount and releaseAllDirect, the manager hooks
the follow-up foreground-service commit wires up.
WakeState zeroes the streaming slot count when the app leaves the
foreground, which stopped the connectedDevice service while a claimed
pad still needed the process: the pad has been reconfigured at the
device level, and only a live process can run the restore a release
performs, so a background kill left it mute until a power cycle.

The service and its controller now count held Direct claims alongside
streaming slots, keep running in the background while any remain, and
exit when the last one goes. A foreground return re-asserts the start
so a service that stopped itself while collection was down comes back
for work still held. The notification's Stop action also releases held
claims, restore included, instead of leaving a captured pad behind, and
its body says what is being held when nothing is streaming.
@emir-hasanbegovic
emir-hasanbegovic merged commit 5d0c009 into main Aug 15, 2026
9 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the feat/steam-controller-usb-direct branch August 15, 2026 17:57
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