Skip to content

Steam Controller (2026) passthrough: Bluetooth LE and Puck - #40

Open
nkarnauh wants to merge 6 commits into
fxgl:mainfrom
nkarnauh:steam-controller-pr
Open

nkarnauh wants to merge 6 commits into
fxgl:mainfrom
nkarnauh:steam-controller-pr

Conversation

@nkarnauh

Copy link
Copy Markdown
Contributor

Steam Controller (2026) currently reaches SteamOS only as the launcher's Xbox 360 uinput pad, and only through its Puck: over Bluetooth LE GameController does not show it at all. This passes it through as itself over the existing uhid path (the same one the DualSense uses), so Steam drives its trackpads, gyro, back buttons and haptics with its own driver. The guest side needed no changes: fx-pad/uhid already take any vendor/product.

What changes

  • Bluetooth LE (28de:1303): passed through with its own IDs and the Bluetooth bus (Bluetooth Low Energy transport mapped to BUS_BLUETOOTH). It does not need a GameController controller.
  • Puck (28de:1304): interfaces 2–5 are the controller slots, with the controller's own 372-byte descriptor. SDL (SDL_hidapi_steam_triton.c) and Steam take the Puck's controllers only by USB interface number 2–5, which a uhid device does not have. So the slot that carries controller state goes to SteamOS as a wired Steam Controller (28de:1302, USB bus), which they take by IDs. A slot without controller state for 2 s is removed (controller off, moved to another slot or to Bluetooth), and the Puck's GameController pad takes over. Wireless status reports (0x79) do not count as state.
  • Settings → Controller → Appears in SteamOS as gets Steam Controller (--pad steamcontroller): only a Steam Controller passed through, any other controller drives an Xbox 360 pad. Automatic passes a Steam Controller through too. It no longer depends on Pass a DualSense through.
  • Input Monitoring: the descriptor has keyboard and mouse collections (the controller's desktop mode), so macOS opens it only with Input Monitoring. When IOHIDManagerOpen returns kIOReturnNotPermitted, the launcher calls IOHIDRequestAccess and retries every 3 s, so granting it takes effect without a restart.
  • GET_REPORT headroom: macOS returns a BLE controller's 64-byte feature report 1 (Steam's command channel) as 65 bytes, so a descriptor-sized buffer failed with kIOReturnNoSpace and Steam never initialised the controller. GET_REPORT buffers now get 64 bytes over the descriptor's sizes.
  • pad state counts Steam Controllers apart from DualSenses. README en/ru/zh: “Steam Controller passthrough”.

Tested

On an M3 Max, macOS 27.0.1, with 1.9.4 (patch rebased onto main without conflicts; the launcher builds on both):

  • BLE: SteamOS sees the Steam Controller itself, passed through, input 0x45, feature reports without errors; Steam shows the controller and it works.
  • Puck: Puck slot 3: controller state (report 0x42, 54 bytes) → SteamOS sees the Valve Steam Controller itself, no IOReturn errors; works in Steam.
  • Switching Appears in SteamOS as between Steam Controller and Xbox 360 swaps the pad live.

Not tested: several Steam Controllers on one Puck, and a Steam Controller as player 2–4. Passthrough goes to player 1 only, as for the DualSense.

…state goes to SteamOS as a wired Steam Controller (28de:1302)
…-pad steamcontroller): only a Steam Controller passed through as itself, other controllers as Xbox 360; Automatic passes it through too, no longer behind Pass a DualSense through

@fxgl fxgl left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Passing the Steam Controller through as itself is very welcome. The BLE bus mapping, re-presenting a Puck slot as a wired 28de:1302, and the per-device input buffer lifetime all look right. Two issues break setups that work today, so I can't merge it as is:

  1. The Steam Controller takes player 1's slot unconditionally (Gamepad.swift:379-382). The Steam Controller branch in wanted() runs before everything else. It doesn't check that player 1's GameController controller is the Steam Controller, and there's no equivalent of the passthroughAllowed() guard the DualSense has (Gamepad.swift:634-636). With the default additional controllers on:

    • DualSense is player 1 and the Steam Controller (via the Puck) is player 2. Slot 0 gets the uhid Steam Controller, slot 1 gets an Xbox 360 pad from the same Steam Controller via GameController. Every press arrives twice, and the DualSense disappears.
    • DualSense plus a Steam Controller over BLE (GameController doesn't see it). Player 1 is the DualSense, but slot 0 passes the Steam Controller through, so the DualSense is dead in SteamOS. This works before the PR.
    • Two Steam Controllers on one Puck. The first live slot in HID order is passed through, and it may not be player 1's controller.

    Suggested fix: take the Steam Controller branch only if player 1 has no GameController controller or it is the Steam Controller itself. Don't take it when another player's GameController controller is the Steam Controller (mirroring passthroughAllowed). Could you log how GameController names the Puck's controller (vendorName / productCategory), so it can be matched?

  2. The Input Monitoring retry never opens anything (HIDPassthrough.swift:128, 136-148).

    • IOHIDManagerOpen on a manager that is already open returns kIOReturnSuccess right away and doesn't touch its devices. I disassembled it on macOS 27.0.1; IOKitUser's IOHIDManager.c has the same require(!manager->isOpen). The TCC denial is also cached per IOHIDDevice.
    • So 3 s after start the timer logs "HID manager opened (Input Monitoring granted)", even before the user has clicked anything, and stops. The devices stay unopened until the launcher restarts or the device is reconnected.
    • The matching callback also fires for devices that failed to open, and the result is ignored ({ ctx, _, _, device in). Non-Puck devices are always live, so an unopened BLE controller is passed through as a dead device: no input, EIO on every GET_REPORT. Together with (1), it also displaces player 1.
    • Devices that appear after start never call awaitPermission.

    Suggested fix: pass result into added, and don't add devices that failed to open (or don't treat them as live); log the reason once. For granting without a restart, poll IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted and then recreate the manager (close, then a new start()). Or drop the timer and log "restart the launcher after granting". Please check this on hardware: README en/ru/zh currently say the controller opens as soon as access is granted.

Worth fixing too:

  • Liveness counts any report except 0x79 (HIDPassthrough.swift:180). SDL treats 0x46 as wireless status as well, and the state reports are 0x42/0x45/0x47. A whitelist of state report IDs avoids phantom uhid devices if the Puck sends 0x46 on an empty slot.
  • The 2 s liveness window assumes the controller keeps sending state. In lizard mode (before Steam in the guest takes the controller), it may only send keyboard/mouse reports when something changes; then it would flap between uhid and the Xbox pad. Could you share a log of an untouched controller over 10+ s, before Steam starts and in a game?
  • The Puck (28de:1304) is matched unconditionally (HIDPassthrough.swift:33, 110-113). Every Puck owner now gets the "receive keystrokes from any application" prompt, even with Xbox 360 or DualSense selected. Match Valve devices only for auto/steamcontroller, or document the prompt.

Nits:

  • puckTimer never stops after the Puck is gone.
  • --help for --pad doesn't mention steamcontroller.
  • The README's pad state text still says "connected DualSenses".
  • Use a monotonic clock (ProcessInfo.systemUptime) instead of Date() for liveness.
  • Please squash it into one commit, which is how PRs land here.

Checked and fine:

  • Both BLE transport strings map to BUS_BLUETOOTH.
  • 1302/1303 are handled by hid-steam and SDL, and SDL's interface 2-5 check justifies re-presenting the slot as wired 1302/USB.
  • GET_REPORT headroom returns the real length to the guest.
  • DualSense passthrough still doesn't need Input Monitoring.
  • The new setting value is safe on rollback.
  • xcstrings is complete, and README en/ru/zh are in sync.
  • It merges cleanly with current main and builds.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants