feat(core,host,android): Steam Controller 2 as-is passthrough to Linux hosts
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m2s
ci / rust (push) Failing after 5m34s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
ci / bench (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 14m2s
arch / build-publish (push) Successful in 11m57s
deb / build-publish (push) Successful in 11m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m5s
flatpak / build-publish (push) Failing after 8m3s
windows-host / package (push) Failing after 8m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m18s
apple / swift (push) Successful in 5m4s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m14s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m43s
release / apple (push) Successful in 26m11s
apple / screenshots (push) Has been cancelled

The 2026 Steam Controller (Valve "Ibex" / SDL "Triton") captured on an
Android client is passed through AS-IS: the host presents a virtual pad
with the real wired identity (28DE:1302) and mirrors the physical pad's
raw HID reports, so Steam on the host drives it over hidraw exactly like
the real thing — trackpads, gyro, paddles, and its rumble/settings writes
flow back onto the physical controller. Protocol ground truth: SDL's
Valve-maintained SDL_hidapi_steam_triton.c + steam/controller_structs.h.

Core:
- GamepadPref::SteamController2 (wire byte 9; names steamcontroller2/
  sc2/ibex) + PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 in the C ABI.
- Raw HID planes: RichInput::HidReport (0xCC/0x04, client→host input
  reports verbatim, Copy fixed-64 body) and HidOutput::HidRaw (0xCD/0x05,
  host→client feature/output writes for replay). Best-effort is sound by
  the device protocol's own design (rumble re-sent every ~40 ms, settings
  every ~3 s — losses self-heal); HidRaw bypasses hidout dedup for
  exactly that reason.

Host (Linux):
- triton_proto.rs + steam_controller2.rs: Triton2Manager UHID backend —
  no kernel driver binds the PID (hidraw only; Steam Input is the
  consumer), raw mirroring with a typed-fallback 0x42 synthesizer until
  the first raw report, SET_REPORT ack + raw forward, canned GET_REPORT
  serial reply, rumble also parsed onto the universal 0xCA plane (phone
  mirror). Rides the uhid + 28DE-conflict degrades; UHID promotion by
  Steam is flagged in the creation log (usbip transport is the known
  follow-up if Steam ignores Interface:-1 devices for Triton too).

Android:
- Sc2UsbLink (wired/Puck: vendor-interface claim detaches the OS driver,
  interrupt read loop, lizard-off on the watchdog cadence, raw replay via
  interrupt-OUT / SET_REPORT with hidapi report-id framing) and Sc2BleLink
  (Valve vendor GATT service, notify subscribe machine, 0x45 re-framing,
  HIGH connection priority).
- Sc2Capture orchestrator: raw plane + typed mirror (exit chord + host
  degrade paths keep working) on a GamepadRouter external slot; raw
  return path via GamepadFeedback.onHidRaw.
- nativeSendPadHidReport JNI (direct ByteBuffer, no per-report copy),
  hidout raw decode, usb-host/BLUETOOTH_CONNECT manifest bits, opt-out
  settings toggle, StreamScreen engagement incl. the USB permission flow.

Verified: core 149 + host 312 tests green on Linux (.21), on-box uhid
smoke creates/mirrors/tears down the virtual 28DE:1302, C ABI harness
round-trips, Android compileDebugKotlin green. On-glass with the real
controller owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 11:22:16 +02:00
parent 705a8baddf
commit 2621b6e6b1
29 changed files with 1977 additions and 40 deletions
@@ -36,6 +36,16 @@ object Gamepad {
const val BTN_X = 0x4000
const val BTN_Y = 0x8000
// Extended bits (Moonlight `buttonFlags2 << 16` namespace — `input.rs::gamepad`): the four
// back grips (Steam L4/L5/R4/R5 ≙ Elite P1P4), touchpad click, and the misc/QAM button.
// Android's standard InputDevice path never produces these; the SC2 capture link does.
const val BTN_PADDLE1 = 0x10000
const val BTN_PADDLE2 = 0x20000
const val BTN_PADDLE3 = 0x40000
const val BTN_PADDLE4 = 0x80000
const val BTN_TOUCHPAD = 0x100000
const val BTN_MISC1 = 0x200000
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
const val AXIS_LS_X = 0
const val AXIS_LS_Y = 1
@@ -54,6 +64,7 @@ object Gamepad {
const val PREF_STEAMDECK = 6
const val PREF_DUALSENSEEDGE = 7
const val PREF_SWITCHPRO = 8
const val PREF_STEAMCONTROLLER2 = 9
// USB vendor ids of the controllers we can identify by VID/PID.
private const val VID_SONY = 0x054C
@@ -51,6 +51,7 @@ class GamepadFeedback(
const val TAG_LED: Byte = 0x01
const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03
const val TAG_HID_RAW: Byte = 0x05
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
// A new host renews far below this, so it never actually holds this long there.
const val LEGACY_RUMBLE_MS = 60_000L
@@ -112,7 +113,8 @@ class GamepadFeedback(
}, "pf-rumble").apply { isDaemon = true; start() }
hidoutThread = Thread({
val buf = ByteBuffer.allocateDirect(64)
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
val buf = ByteBuffer.allocateDirect(128)
while (running) {
val n = NativeBridge.nativeNextHidout(handle, buf)
if (n < 0) continue // timeout / closed
@@ -331,10 +333,32 @@ class GamepadFeedback(
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
)
}
TAG_HID_RAW -> {
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
// [kind: 0=output, 1=feature][report bytes, id first]. Handed to the capture link
// for verbatim replay on the physical controller; dropped when no link owns the pad.
val kind = buf.get().toInt() and 0xFF
val len = n - 3
if (len > 0) {
val data = ByteArray(len)
buf.get(data)
onHidRaw?.invoke(pad, kind, data)
}
}
else -> Log.d(TAG, "hidout: unknown kind, dropped")
}
}
/**
* Raw HID-report replay hook for the as-is Steam Controller 2 passthrough: invoked (on the
* hidout poll thread) with the wire pad index, the report kind (0 = output report, 1 =
* feature report), and the full report bytes (id first) the host's hidraw consumer wrote.
* `StreamScreen` wires this to the SC2 capture so Steam's rumble/settings land on the
* physical controller.
*/
@Volatile
var onHidRaw: ((pad: Int, kind: Int, data: ByteArray) -> Unit)? = null
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
0b00000 -> 0
@@ -91,21 +91,29 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
fun onButton(event: KeyEvent, bit: Int) {
val slot = slotFor(event.device) ?: return
when (event.action) {
KeyEvent.ACTION_DOWN -> {
// repeatCount guard: don't re-send a held button as auto-repeat.
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
}
KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
disarmExit()
}
// repeatCount guard: don't re-send a held button as auto-repeat.
KeyEvent.ACTION_DOWN -> slotButton(slot, bit, down = true, send = event.repeatCount == 0)
KeyEvent.ACTION_UP -> slotButton(slot, bit, down = false, send = true)
}
}
/**
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
if (down) {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
} else {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
disarmExit()
}
}
}
@@ -152,8 +160,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/**
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
* holds no live slot (a pad that just unplugged — the update is then dropped). Read from the
* feedback poll threads.
* holds no live slot (a pad that just unplugged — the update is then dropped) OR the slot is
* an [ExternalPad] (its synthetic id resolves to no InputDevice, so rumble binds naturally
* fall through to the capture link's own feedback path). Read from the feedback poll threads.
*/
fun deviceForPad(pad: Int): InputDevice? {
for ((deviceId, slot) in slots) {
@@ -162,6 +171,50 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
return null
}
/**
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
* flush + Remove on [close], and full participation in the emergency exit chord.
*/
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
// Live lookup instead of a captured reference: after [close] (or a router release) the
// slot is gone from the table and every entry point below degrades to a safe no-op.
private val slot get() = slots[syntheticId]
/** One button transition (a wire [Gamepad].BTN_* bit). On-change only — the caller diffs. */
fun button(bit: Int, down: Boolean) {
slot?.let { slotButton(it, bit, down, send = true) }
}
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
fun axis(id: Int, value: Int) {
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
}
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
fun close() = closeSlot(syntheticId)
}
/**
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
* indices are taken. Main thread (like the hot-plug callbacks).
*/
fun openExternal(pref: Int): ExternalPad? {
val index = lowestFreeIndex() ?: return null
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
val syntheticId = EXTERNAL_ID_BASE - index
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index)
}
/**
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]).
@@ -252,5 +305,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
const val EXIT_HOLD_MS = 1500L
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
}
}
@@ -301,6 +301,14 @@ object NativeBridge {
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
/**
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
* first [len] bytes are the report, id byte first (0x42/0x45/0x47 state, 0x43 battery, …);
* len is clamped to 64. Called from the capture thread at the controller's own report rate.
*/
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/**
@@ -312,10 +320,11 @@ object NativeBridge {
external fun nativeNextRumble(handle: Long): Long
/**
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
* byte count, or -1 on timeout / session closed.
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
* capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
* Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…, raw as-is
* passthrough report=pad 05 kind report-bytes (kind 0 = output report, 1 = feature report).
* Returns the byte count, or -1 on timeout / session closed.
*/
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
}
@@ -0,0 +1,241 @@
package io.unom.punktfunk.kit
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.util.Log
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
/**
* BLE transport for a Steam Controller 2 paired directly with the device (no Puck). The standard
* HID service (0x1812) is claimed by the OS (and would feed the pad through the ordinary input
* stack in lizard-crippled form), so this talks Valve's vendor GATT service instead — the same
* approach Steam itself uses on hosts without a dongle.
*
* GATT operations are serialized by a small state machine (connect → MTU → discover → subscribe
* each notify char → lizard-off → ready); duplicate callbacks (the Android stack sometimes fires
* `onMtuChanged` twice) are ignored. Notified state reports arrive with the report-id byte
* stripped by the transport, so `0x45` (`ID_STATE_BLE`) is re-prepended for ≥40-byte payloads —
* the wire then carries the same id-first framing as USB.
*
* Requires BLUETOOTH_CONNECT (the caller gates on it); connection priority is bumped to HIGH to
* pull the connection interval from ~50 ms down to ~11 ms.
*/
@SuppressLint("MissingPermission")
class Sc2BleLink(
private val context: Context,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
) {
private enum class State { IDLE, CONNECTING, MTU_REQUESTED, DISCOVERING, SUBSCRIBING, READY }
private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private var gatt: BluetoothGatt? = null
private var writeChar: BluetoothGattCharacteristic? = null
private val pendingSubs = mutableListOf<BluetoothGattCharacteristic>()
private var subsIndex = 0
private val writeBusy = AtomicBoolean(false)
private var lizardTicker: Thread? = null
@Volatile private var state = State.IDLE
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
fun pairedControllers(): List<BluetoothDevice> = runCatching {
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
}
}.getOrDefault(emptyList())
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
fun start(address: String): Boolean {
val adapter = manager.adapter ?: return false
if (!adapter.isEnabled) return false
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
state = State.CONNECTING
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
return true
}
/**
* Replay one raw report from the host: output reports (rumble) ride WRITE_NO_RESPONSE so they
* can't queue behind acks at the 25 Hz resend rate; feature reports (settings) use an acked
* write. The report-id byte stays in the payload (the firmware's vendor-channel framing).
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (state != State.READY || data.isEmpty()) return
val g = gatt ?: return
val ch = writeChar ?: return
runCatching {
ch.value = data
ch.writeType = if (kind == 0) {
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
} else {
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
}
g.writeCharacteristic(ch)
}
}
private fun sendLizardOff() {
if (state != State.READY) return
val g = gatt ?: return
val ch = writeChar ?: return
if (!writeBusy.compareAndSet(false, true)) return // previous acked write still in flight
runCatching {
ch.value = Sc2Device.DISABLE_LIZARD
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
if (!g.writeCharacteristic(ch)) writeBusy.set(false)
}.onFailure { writeBusy.set(false) }
}
/** Disconnect and stop the lizard ticker. Idempotent; does not fire [onClosed]. */
fun stop() {
lizardTicker?.interrupt()
lizardTicker = null
runCatching { gatt?.disconnect() }
runCatching { gatt?.close() }
gatt = null
writeChar = null
pendingSubs.clear()
subsIndex = 0
state = State.IDLE
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
// ~11 ms connection interval instead of the ~50 ms default — input latency.
g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
if (state == State.CONNECTING) {
state = State.MTU_REQUESTED
if (!g.requestMtu(DESIRED_MTU)) {
state = State.DISCOVERING
g.discoverServices()
}
}
}
BluetoothProfile.STATE_DISCONNECTED -> {
val wasLive = state != State.IDLE
runCatching { g.close() }
gatt = null
writeChar = null
pendingSubs.clear()
subsIndex = 0
state = State.IDLE
if (wasLive) onClosed()
}
}
}
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
if (state != State.MTU_REQUESTED) return // fired twice on some stacks — act once
state = State.DISCOVERING
g.discoverServices()
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (state != State.DISCOVERING || status != BluetoothGatt.GATT_SUCCESS) return
val valve = g.getService(VALVE_SERVICE) ?: run {
Log.e(TAG, "Valve vendor service missing — not an SC2?")
return
}
pendingSubs.clear()
writeChar = null
for (ch in valve.characteristics) {
val short = shortUuid(ch.uuid) ?: continue
val canNotify = ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
val canWrite = ch.properties and (
BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE
) != 0
if (canNotify && short in NOTIFY_LOW..NOTIFY_HIGH) pendingSubs.add(ch)
if (canWrite && short in WRITE_LOW..WRITE_HIGH && writeChar == null) writeChar = ch
}
subsIndex = 0
state = State.SUBSCRIBING
subscribeNext(g)
}
override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) {
if (state == State.SUBSCRIBING) subscribeNext(g)
}
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
writeBusy.set(false)
}
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
val data = ch.value ?: return
// BLE strips the report-id prefix; restore 0x45 on state-sized payloads so the raw
// wire framing matches USB. Short payloads (battery/status) pass through as-is.
if (data.size >= 40) {
val framed = ByteArray(data.size + 1)
framed[0] = Sc2Device.ID_STATE_BLE.toByte()
System.arraycopy(data, 0, framed, 1, data.size)
onReport(framed, framed.size)
} else {
onReport(data, data.size)
}
}
}
private fun subscribeNext(g: BluetoothGatt) {
if (subsIndex >= pendingSubs.size) {
state = State.READY
Log.i(TAG, "SC2 BLE link up (${pendingSubs.size} notify chars)")
sendLizardOff()
// The firmware watchdog re-enables lizard mode; refresh on SDL's cadence until the
// host's Steam takes over via the raw plane (its writes land through writeRaw too).
lizardTicker = Thread({
while (state == State.READY) {
try {
Thread.sleep(Sc2Device.LIZARD_REFRESH_MS)
} catch (_: InterruptedException) {
return@Thread
}
sendLizardOff()
}
}, "pf-sc2-lizard").apply { isDaemon = true; start() }
return
}
val ch = pendingSubs[subsIndex++]
g.setCharacteristicNotification(ch, true)
val cccd = ch.getDescriptor(CCCD) ?: return subscribeNext(g)
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (!g.writeDescriptor(cccd)) subscribeNext(g) // lose this one, try the rest
}
/** The 32-bit short id of a Valve vendor UUID, or null for foreign UUIDs. */
private fun shortUuid(uuid: UUID): Long? {
val s = uuid.toString()
if (!s.endsWith(VALVE_UUID_TAIL)) return null
return s.substring(0, 8).toLongOrNull(16)
}
private companion object {
const val TAG = "Sc2BleLink"
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
const val NOTIFY_LOW = 0x100f6c75L
const val NOTIFY_HIGH = 0x100f6c7aL
const val WRITE_LOW = 0x100f6cb5L
const val WRITE_HIGH = 0x100f6cbeL
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
/** Enough for a state payload (45 B) + ATT header with margin. */
const val DESIRED_MTU = 100
}
}
@@ -0,0 +1,161 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.usb.UsbDevice
import android.util.Log
import java.nio.ByteBuffer
/**
* One captured Steam Controller 2 for one stream session — the glue between a transport link
* ([Sc2UsbLink] / [Sc2BleLink]) and the wire:
*
* - **Raw plane (the point):** every input report is forwarded verbatim
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
* Steam Input drives like the physical controller.
* - **Typed mirror:** buttons/sticks/triggers are ALSO diffed onto the ordinary per-transition
* plane, so the emergency exit chord works, and a host that degraded the kind (no UHID → the
* Xbox 360 pad) still gets a playable controller.
* - **Raw return:** the host's hidraw writes (Steam's `0x80` rumble output reports, lizard/IMU
* feature settings) arrive via [GamepadFeedback.onHidRaw] → [onHidRaw] → the link, landing on
* the real controller's motors/firmware.
*
* The wire slot is claimed lazily on the FIRST state report — a Puck with no controller powered
* on stays invisible to the host — and released (with a wireless-disconnect event or on [stop])
* so pad indices never leak. Report callbacks arrive on the link's own thread; the router's slot
* table and chord timer are thread-safe for this (same contract as the feedback poll threads).
*/
class Sc2Capture(
context: Context,
private val router: GamepadRouter,
) {
private val usb = Sc2UsbLink(context, ::onReport, ::onLinkClosed)
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
private var activeLink: Int = LINK_NONE
private var pad: GamepadRouter.ExternalPad? = null
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
// Typed-mirror diff state (wire units).
private val state = Sc2Device.State()
private var wireButtons = 0
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
/** First attached SC2/Puck USB device, for the permission flow. */
fun findUsbDevice(): UsbDevice? = usb.findDevice()
/**
* The first already-bonded BLE Steam Controller's address, or null. The caller checks
* BLUETOOTH_CONNECT first (without it the bonded list reads as empty anyway).
*/
fun pairedBleAddress(): String? = ble.pairedControllers().firstOrNull()?.address
/** Start capturing [dev] over USB (permission already granted). */
fun startUsb(dev: UsbDevice): Boolean {
if (activeLink != LINK_NONE) return false
val ok = usb.start(dev)
if (ok) activeLink = LINK_USB
return ok
}
/** Start capturing the bonded BLE controller at [address]. */
fun startBle(address: String): Boolean {
if (activeLink != LINK_NONE) return false
val ok = ble.start(address)
if (ok) activeLink = LINK_BLE
return ok
}
/** Replay a host raw write on the physical pad — wire to [GamepadFeedback.onHidRaw]. */
fun onHidRaw(padIndex: Int, kind: Int, data: ByteArray) {
if (padIndex != pad?.index) return // addressed to some other controller
when (activeLink) {
LINK_USB -> usb.writeRaw(kind, data)
LINK_BLE -> ble.writeRaw(kind, data)
}
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
when (activeLink) {
LINK_USB -> usb.stop()
LINK_BLE -> ble.stop()
}
activeLink = LINK_NONE
releaseSlot()
}
// ---- link callbacks (link thread) ----
private fun onReport(report: ByteArray, len: Int) {
val id = report[0].toInt() and 0xFF
// A Puck relays connect/disconnect for its controller — track the slot accordingly, so
// powering the pad off frees its wire index (and the host's virtual device).
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
if ((report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) releaseSlot()
return
}
if (!Sc2Device.parseState(report, len, state)) {
// Battery/status and future report types still belong to the as-is stream.
forwardRaw(report, len)
return
}
val p = pad ?: router.openExternal(Gamepad.PREF_STEAMCONTROLLER2)?.also {
pad = it
Log.i(TAG, "SC2 captured → wire pad ${it.index} (as-is passthrough)")
} ?: return // all 16 wire indices taken — drop until one frees
forwardRaw(report, len)
mirrorTyped(p)
}
private fun forwardRaw(report: ByteArray, len: Int) {
val p = pad ?: return
val n = len.coerceAtMost(rawBuf.capacity())
rawBuf.clear()
rawBuf.put(report, 0, n)
p.hidReport(rawBuf, n)
}
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
val wired = Sc2Device.wireButtons(state.buttons)
var changed = wired xor wireButtons
while (changed != 0) {
val bit = changed and -changed // lowest changed bit
p.button(bit, wired and bit != 0)
changed = changed and bit.inv()
}
wireButtons = wired
axis(p, Gamepad.AXIS_LS_X, state.lsX)
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
axis(p, Gamepad.AXIS_RS_X, state.rsX)
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
axis(p, Gamepad.AXIS_LT, state.lt)
axis(p, Gamepad.AXIS_RT, state.rt)
}
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
if (lastAxis[id] == v) return
lastAxis[id] = v
p.axis(id, v)
}
private fun onLinkClosed() {
Log.i(TAG, "SC2 link closed (unplug / power-off)")
activeLink = LINK_NONE
releaseSlot()
}
private fun releaseSlot() {
pad?.close()
pad = null
wireButtons = 0
lastAxis.fill(Int.MIN_VALUE)
}
private companion object {
const val TAG = "Sc2Capture"
const val LINK_NONE = 0
const val LINK_USB = 1
const val LINK_BLE = 2
}
}
@@ -0,0 +1,151 @@
package io.unom.punktfunk.kit
/**
* Steam Controller 2 (2026, Valve "Ibex" / SDL "Triton") protocol constants + the light state
* parser the CLIENT needs. The full report rides the wire verbatim (`nativeSendPadHidReport` →
* the host's as-is virtual pad); this parser only extracts what the client itself consumes: the
* button word for the typed mirror + exit chord, and sticks/triggers for the degrade path.
*
* Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
* (Valve-maintained), mirrored host-side in `punktfunk-host`'s `triton_proto.rs`.
*/
object Sc2Device {
const val VID_VALVE = 0x28DE
/** Wired controller. */
const val PID_WIRED = 0x1302
/** Direct BLE identity (transport handled by [Sc2BleLink], not USB). */
const val PID_BLE = 0x1303
/** The wireless Puck dongles (Proteus / Nereid) — controller on USB interfaces 2..5. */
const val PID_DONGLE_PROTEUS = 0x1304
const val PID_DONGLE_NEREID = 0x1305
val USB_PIDS = setOf(PID_WIRED, PID_DONGLE_PROTEUS, PID_DONGLE_NEREID)
/** Dongle interface range that carries controllers (SDL: "interfaces 2..5, currently"). */
val DONGLE_IFACES = 2..5
// Input report ids (`ETritonReportIDTypes`). State layouts share every offset the client
// reads (seq/buttons/triggers/sticks); 0x47 only diverges from byte 18 (trackpad timestamp).
const val ID_STATE = 0x42
const val ID_BATTERY = 0x43
const val ID_STATE_BLE = 0x45
const val ID_WIRELESS_X = 0x46
const val ID_STATE_TIMESTAMP = 0x47
const val ID_WIRELESS = 0x79
/** Wireless status payload byte: controller connected/disconnected through the Puck. */
const val WIRELESS_DISCONNECT = 1
const val WIRELESS_CONNECT = 2
// Button bits in the state report's u32 (SDL `TritonButtons`).
const val A = 0x00000001
const val B = 0x00000002
const val X = 0x00000004
const val Y = 0x00000008
const val QAM = 0x00000010
const val R3 = 0x00000020
const val VIEW = 0x00000040
const val R4 = 0x00000080
const val R5 = 0x00000100
const val RB = 0x00000200
const val DPAD_DOWN = 0x00000400
const val DPAD_RIGHT = 0x00000800
const val DPAD_LEFT = 0x00001000
const val DPAD_UP = 0x00002000
const val MENU = 0x00004000
const val L3 = 0x00008000
const val STEAM = 0x00010000
const val L4 = 0x00020000
const val L5 = 0x00040000
const val LB = 0x00080000
const val RPAD_CLICK = 0x00400000
/**
* The feature report that turns lizard mode (built-in keyboard/mouse emulation) off:
* `[report id 1][ID_SET_SETTINGS_VALUES 0x87][length 3][SETTING_LIZARD_MODE 9]
* [LIZARD_MODE_OFF u16]`, zero-padded to the 64-byte feature size. The firmware watchdog
* re-enables lizard mode after a few seconds of silence, so this is re-sent every
* [LIZARD_REFRESH_MS] (SDL's cadence) — and the host's Steam sends its own through the raw
* plane once it grabs the virtual pad, which lands here too.
*/
val DISABLE_LIZARD: ByteArray = ByteArray(64).also {
it[0] = 0x01 // feature report id
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
it[3] = 9 // SETTING_LIZARD_MODE
// [4..6] = LIZARD_MODE_OFF (0) — already zero
}
const val LIZARD_REFRESH_MS = 3000L
/** Wire mapping: SC2 button bit → punktfunk `Gamepad.BTN_*`, the inverse of the host's
* typed-fallback mapping (`triton_proto::from_gamepad`): paddles R4/L4/R5/L5 =
* PADDLE1/2/3/4, QAM = MISC1, right-pad click = the touchpad wire bit. */
private val WIRE_MAP = intArrayOf(
A, Gamepad.BTN_A,
B, Gamepad.BTN_B,
X, Gamepad.BTN_X,
Y, Gamepad.BTN_Y,
LB, Gamepad.BTN_LB,
RB, Gamepad.BTN_RB,
VIEW, Gamepad.BTN_BACK,
MENU, Gamepad.BTN_START,
STEAM, Gamepad.BTN_GUIDE,
L3, Gamepad.BTN_LS_CLICK,
R3, Gamepad.BTN_RS_CLICK,
DPAD_UP, Gamepad.BTN_DPAD_UP,
DPAD_DOWN, Gamepad.BTN_DPAD_DOWN,
DPAD_LEFT, Gamepad.BTN_DPAD_LEFT,
DPAD_RIGHT, Gamepad.BTN_DPAD_RIGHT,
QAM, Gamepad.BTN_MISC1,
R4, Gamepad.BTN_PADDLE1,
L4, Gamepad.BTN_PADDLE2,
R5, Gamepad.BTN_PADDLE3,
L5, Gamepad.BTN_PADDLE4,
RPAD_CLICK, Gamepad.BTN_TOUCHPAD,
)
/** Translate an SC2 button word into the wire `Gamepad.BTN_*` bitmask. */
fun wireButtons(sc2: Int): Int {
var out = 0
var i = 0
while (i < WIRE_MAP.size) {
if (sc2 and WIRE_MAP[i] != 0) out = out or WIRE_MAP[i + 1]
i += 2
}
return out
}
/** The typed-mirror fields of one state report (buttons/sticks/triggers only). */
class State {
var buttons = 0 // SC2 bit layout
var lsX = 0; var lsY = 0 // i16, +y = up (device convention = wire convention)
var rsX = 0; var rsY = 0
var lt = 0; var rt = 0 // 0..255 (device 0..32767 scaled down)
}
/**
* Parse the client-consumed fields out of a state report (`0x42`/`0x45`/`0x47` — identical
* offsets for everything read here) into [out]. Returns false for non-state / short reports.
*/
fun parseState(report: ByteArray, len: Int, out: State): Boolean {
if (len < 18) return false
when (report[0].toInt() and 0xFF) {
ID_STATE, ID_STATE_BLE, ID_STATE_TIMESTAMP -> {}
else -> return false
}
fun i16(o: Int) = ((report[o + 1].toInt() shl 8) or (report[o].toInt() and 0xFF)).toShort().toInt()
out.buttons = (report[2].toInt() and 0xFF) or
((report[3].toInt() and 0xFF) shl 8) or
((report[4].toInt() and 0xFF) shl 16) or
((report[5].toInt() and 0xFF) shl 24)
out.lt = (i16(6).coerceIn(0, 32767)) shr 7
out.rt = (i16(8).coerceIn(0, 32767)) shr 7
out.lsX = i16(10); out.lsY = i16(12)
out.rsX = i16(14); out.rsY = i16(16)
return true
}
}
@@ -0,0 +1,223 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.util.Log
/**
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
* dongle (`1304`/`1305`, controller on interfaces 2..5). Claims the Valve vendor interface
* (detaching any kernel/OS consumer), runs a read loop on its interrupt-IN endpoint, keeps
* lizard mode off on the firmware watchdog cadence, and replays the host's raw writes (Steam's
* rumble output reports / settings feature reports) back to the device.
*
* One controller per link in v1: on a dongle the first claimable controller interface wins
* (multi-pad-per-Puck is a follow-up).
*/
class Sc2UsbLink(
context: Context,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
) {
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
private var connection: UsbDeviceConnection? = null
private var iface: UsbInterface? = null
private var epIn: UsbEndpoint? = null
private var epOut: UsbEndpoint? = null
private var reader: Thread? = null
@Volatile private var running = false
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
}
/**
* Claim [dev] and start the read + lizard-heartbeat loop. The caller has already obtained
* USB permission ([UsbManager.hasPermission]). Returns false when no controller interface
* could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(TAG, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterface(dev, conn) ?: run {
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
iface = claimed.first
epIn = claimed.second
epOut = claimed.third
running = true
Log.i(
TAG,
"SC2 USB link up: PID=0x%04x iface=%d in=0x%02x out=%s".format(
dev.productId, claimed.first.id, claimed.second.address,
claimed.third?.let { "0x%02x".format(it.address) } ?: "control",
),
)
writeFeature(Sc2Device.DISABLE_LIZARD)
reader = Thread({ readLoop(conn, claimed.second) }, "pf-sc2-usb").apply {
isDaemon = true
start()
}
return true
}
/**
* Pick the controller interface: vendor-defined (0xFF) class with an interrupt/bulk IN
* endpoint, restricted to interfaces 2..5 on a Puck dongle (the SDL-documented controller
* range — the other interfaces are the dongle's own control/lizard endpoints).
*/
private fun claimControllerInterface(
dev: UsbDevice,
conn: UsbDeviceConnection,
): Triple<UsbInterface, UsbEndpoint, UsbEndpoint?>? {
val dongle = dev.productId != Sc2Device.PID_WIRED
val candidates = (0 until dev.interfaceCount)
.map { dev.getInterface(it) }
.filter { !dongle || it.id in Sc2Device.DONGLE_IFACES }
.sortedByDescending {
when (it.interfaceClass) {
0xFF -> 2 // vendor-defined first — the Valve gamepad interface
UsbConstants.USB_CLASS_HID -> 1
else -> 0
}
}
for (candidate in candidates) {
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (i in 0 until candidate.endpointCount) {
val ep = candidate.getEndpoint(i)
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
if (!usable) continue
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
}
if (inEp == null) continue
// force=true detaches the kernel/OS driver — while claimed, the controller vanishes
// from Android's own input stack (no double input alongside our capture).
if (conn.claimInterface(candidate, true)) return Triple(candidate, inEp, outEp)
Log.w(TAG, "could not claim iface ${candidate.id}, trying next")
}
return null
}
private fun readLoop(conn: UsbDeviceConnection, ep: UsbEndpoint) {
val buf = ByteArray(64)
var lastLizard = 0L
var failures = 0
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
writeFeature(Sc2Device.DISABLE_LIZARD)
lastLizard = now
}
val n = conn.bulkTransfer(ep, buf, buf.size, READ_TIMEOUT_MS)
when {
n > 0 -> {
failures = 0
onReport(buf, n)
}
n == 0 -> {} // empty read — keep going
else -> {
// -1 covers both timeout (normal, idle controller) and unplug. A real unplug
// makes every subsequent transfer fail instantly, so many consecutive fast
// failures = the device is gone.
if (++failures >= 64) {
Log.i(TAG, "SC2 USB read failing persistently — treating as unplug")
break
}
}
}
}
if (running) {
running = false
onClosed()
}
}
/**
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
* rumble & friends — interrupt-OUT when the interface has one, else a `SET_REPORT(Output)`
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
* report, id byte first, exactly as hidapi framed it host-side.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
0 -> {
val out = epOut
val conn = connection ?: return
if (out != null) {
conn.bulkTransfer(out, data, data.size, WRITE_TIMEOUT_MS)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> writeFeature(data)
}
}
private fun writeFeature(data: ByteArray) {
setReport(REPORT_TYPE_FEATURE, data)
}
/**
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
* "unnumbered" (id 0 in wValue, id byte stripped from the payload).
*/
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = iface?.id ?: return
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interface. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
runCatching { reader?.join(1000) }
reader = null
runCatching { iface?.let { connection?.releaseInterface(it) } }
runCatching { connection?.close() }
connection = null
iface = null
epIn = null
epOut = null
}
private companion object {
const val TAG = "Sc2UsbLink"
const val READ_TIMEOUT_MS = 100
const val WRITE_TIMEOUT_MS = 250
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
}