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
@@ -27,6 +27,10 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Gamepad rumble feedback. --> <!-- Gamepad rumble feedback. -->
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on <!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
devices without a gamepad. --> devices without a gamepad. -->
@@ -40,6 +44,10 @@
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). --> ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
<uses-feature android:name="android.hardware.microphone" android:required="false" /> <uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" /> <uses-feature android:name="android.hardware.wifi" android:required="false" />
<!-- Steam Controller 2 capture: USB host for the wired pad / Puck dongle, Bluetooth for the
direct-BLE pad — both optional (the feature quietly disengages without them). -->
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned. <!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
@@ -90,6 +90,15 @@ data class Settings(
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op. * toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
*/ */
val rumbleOnPhone: Boolean = false, val rumbleOnPhone: Boolean = false,
/**
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
* and pass it through AS-IS: the host presents a real `28DE:1302` that its Steam drives
* directly (Linux hosts). ON by default — it engages only when such a controller is actually
* present at stream start, so it costs nothing otherwise; the toggle exists for the rare
* setup where the OS-level pad (lizard mode) is preferred.
*/
val sc2Capture: Boolean = true,
) )
/** [Settings.touchMode] values; persisted by name. */ /** [Settings.touchMode] values; persisted by name. */
@@ -151,6 +160,7 @@ class SettingsStore(context: Context) {
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true), autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false), rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -172,6 +182,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode) .putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled) .putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone) .putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.apply() .apply()
} }
@@ -208,6 +219,7 @@ class SettingsStore(context: Context) {
const val K_LOW_LATENCY = "low_latency_mode_v2" const val K_LOW_LATENCY = "low_latency_mode_v2"
const val K_AUTO_WAKE = "auto_wake_enabled" const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone" const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */ /** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
@@ -426,6 +426,14 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
checked = s.rumbleOnPhone, checked = s.rumbleOnPhone,
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) }, onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
) )
ToggleRow(
title = "Steam Controller 2 passthrough",
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
"Bluetooth) and pass it through as-is — Steam on the host drives it like " +
"the physical pad (trackpads, gyro, haptics)",
checked = s.sc2Capture,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
} }
} }
} }
@@ -1,9 +1,14 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.Manifest import android.Manifest
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import android.text.InputType import android.text.InputType
@@ -43,6 +48,7 @@ import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -212,9 +218,59 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights // Free a disconnected controller's rumble/lights bindings promptly (else the open lights
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
router.onSlotClosed = feedback::onDeviceRemoved router.onSlotClosed = feedback::onDeviceRemoved
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
// already-paired BLE one — and forward its raw reports; the host mirrors a real
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
// through feedback.onHidRaw onto the physical controller. Engages only when such a pad is
// actually present; the wire slot is claimed lazily on its first state report.
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
var sc2UsbReceiver: BroadcastReceiver? = null
if (sc2 != null) {
feedback.onHidRaw = sc2::onHidRaw
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = sc2.findUsbDevice()
when {
usbDev != null && usbManager.hasPermission(usbDev) -> sc2.startUsb(usbDev)
usbDev != null -> {
// One-time system dialog; capture engages on grant (Android remembers the
// grant for as long as the device stays attached).
val action = "io.unom.punktfunk.SC2_USB_PERMISSION"
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != action) return
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (ok) sc2.startUsb(usbDev) else Log.i("punktfunk", "SC2 USB permission denied")
}
}
sc2UsbReceiver = receiver
ContextCompat.registerReceiver(
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
)
usbManager.requestPermission(
usbDev,
PendingIntent.getBroadcast(
context, 0,
Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
sc2.pairedBleAddress()?.let { addr ->
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
sc2.startBle(addr)
}
}
}
}
onDispose { onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
feedback.onHidRaw = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null activity?.gamepadRouter = null
activity?.streamHandle = 0L activity?.streamHandle = 0L
@@ -36,6 +36,16 @@ object Gamepad {
const val BTN_X = 0x4000 const val BTN_X = 0x4000
const val BTN_Y = 0x8000 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_*`. // Axis ids — must equal `input.rs::gamepad::AXIS_*`.
const val AXIS_LS_X = 0 const val AXIS_LS_X = 0
const val AXIS_LS_Y = 1 const val AXIS_LS_Y = 1
@@ -54,6 +64,7 @@ object Gamepad {
const val PREF_STEAMDECK = 6 const val PREF_STEAMDECK = 6
const val PREF_DUALSENSEEDGE = 7 const val PREF_DUALSENSEEDGE = 7
const val PREF_SWITCHPRO = 8 const val PREF_SWITCHPRO = 8
const val PREF_STEAMCONTROLLER2 = 9
// USB vendor ids of the controllers we can identify by VID/PID. // USB vendor ids of the controllers we can identify by VID/PID.
private const val VID_SONY = 0x054C private const val VID_SONY = 0x054C
@@ -51,6 +51,7 @@ class GamepadFeedback(
const val TAG_LED: Byte = 0x01 const val TAG_LED: Byte = 0x01
const val TAG_PLAYER_LEDS: Byte = 0x02 const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03 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. // 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. // A new host renews far below this, so it never actually holds this long there.
const val LEGACY_RUMBLE_MS = 60_000L const val LEGACY_RUMBLE_MS = 60_000L
@@ -112,7 +113,8 @@ class GamepadFeedback(
}, "pf-rumble").apply { isDaemon = true; start() } }, "pf-rumble").apply { isDaemon = true; start() }
hidoutThread = Thread({ 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) { while (running) {
val n = NativeBridge.nativeNextHidout(handle, buf) val n = NativeBridge.nativeNextHidout(handle, buf)
if (n < 0) continue // timeout / closed 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), "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") 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. */ /** 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) { private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
0b00000 -> 0 0b00000 -> 0
@@ -91,21 +91,29 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
fun onButton(event: KeyEvent, bit: Int) { fun onButton(event: KeyEvent, bit: Int) {
val slot = slotFor(event.device) ?: return val slot = slotFor(event.device) ?: return
when (event.action) { when (event.action) {
KeyEvent.ACTION_DOWN -> { // repeatCount guard: don't re-send a held button as auto-repeat.
// repeatCount guard: don't re-send a held button as auto-repeat. KeyEvent.ACTION_DOWN -> slotButton(slot, bit, down = true, send = event.repeatCount == 0)
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) KeyEvent.ACTION_UP -> slotButton(slot, bit, down = false, send = true)
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 -> { * One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) * transitions: forward the wire event, track held state, and arm/disarm the exit chord.
slot.held = slot.held and bit.inv() */
// A chord button lifted before the hold elapsed → cancel, unless another pad still private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// holds the full chord. if (down) {
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) { if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
disarmExit() 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 * 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 * holds no live slot (a pad that just unplugged — the update is then dropped) OR the slot is
* feedback poll threads. * 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? { fun deviceForPad(pad: Int): InputDevice? {
for ((deviceId, slot) in slots) { for ((deviceId, slot) in slots) {
@@ -162,6 +171,50 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
return null 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 * Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]). * 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`. */ /** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
const val EXIT_HOLD_MS = 1500L 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. */ /** 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) 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) ---- // ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/** /**
@@ -312,10 +320,11 @@ object NativeBridge {
external fun nativeNextRumble(handle: Long): Long external fun nativeNextRumble(handle: Long): Long
/** /**
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct * Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to * capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the * Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…, raw as-is
* byte count, or -1 on timeout / session closed. * 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 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
}
}
+15
View File
@@ -22,6 +22,7 @@ const PULL_TIMEOUT: Duration = Duration::from_millis(100);
const TAG_LED: u8 = 0x01; const TAG_LED: u8 = 0x01;
const TAG_PLAYER_LEDS: u8 = 0x02; const TAG_PLAYER_LEDS: u8 = 0x02;
const TAG_TRIGGER: u8 = 0x03; const TAG_TRIGGER: u8 = 0x03;
const TAG_HID_RAW: u8 = 0x05;
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update. /// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease", /// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
@@ -143,6 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
// rumble already rides the universal 0xCA plane). // rumble already rides the universal 0xCA plane).
return -1; return -1;
} }
HidOutput::HidRaw { pad, kind, data } => {
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
// the virtual pad; Kotlin replays it verbatim on the physical controller.
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
let n = 3 + data.len();
if cap < n {
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
}
out[0] = pad;
out[1] = TAG_HID_RAW;
out[2] = kind;
out[3..n].copy_from_slice(&data);
n
}
}; };
n as jint n as jint
}) })
+42 -1
View File
@@ -6,10 +6,11 @@
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal, //! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side). //! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
use jni::objects::JObject; use jni::objects::{JByteBuffer, JObject};
use jni::sys::{jboolean, jint, jlong}; use jni::sys::{jboolean, jint, jlong};
use jni::JNIEnv; use jni::JNIEnv;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
use super::SessionHandle; use super::SessionHandle;
@@ -236,3 +237,43 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
) { ) {
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32); send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
} }
/// `NativeBridge.nativeSendPadHidReport(handle, pad, buf, len)` — one raw HID input report from a
/// client-captured controller (the as-is Steam Controller 2 passthrough), forwarded verbatim on
/// the rich-input plane (`RichInput::HidReport`, 0xCC). `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 the 64-byte wire body. Called from the capture thread at the controller's
/// own report rate (~250500 Hz) — the direct-buffer read avoids a JNI array copy per report.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
buf: JByteBuffer,
len: jint,
) {
if handle == 0 || len <= 0 {
return;
}
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return,
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return,
};
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
let mut data = [0u8; HID_REPORT_MAX];
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
// `n` is bounded by both the buffer capacity and the fixed wire body.
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::HidReport {
pad: (pad as u32 & 0xF) as u8,
len: n as u8,
data,
});
}
@@ -258,6 +258,11 @@ public final class PunktfunkConnection {
/// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo /// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo
/// glyphs + positional layout on the host side. /// glyphs + positional layout on the host side.
case switchPro = 8 case switchPro = 8
/// New Steam Controller (2026, `28DE:1302`), passed through as-is on Linux hosts (raw
/// report mirroring; Steam Input is the consumer). Parity only on Apple GameController
/// never surfaces the raw Valve device, so the client can't capture one; exists so the
/// resolved type round-trips and name parsing matches the host.
case steamController2 = 9
/// Loose name parsing for env/dev hooks, mirroring the host's /// Loose name parsing for env/dev hooks, mirroring the host's
/// `GamepadPref::from_name`. /// `GamepadPref::from_name`.
@@ -270,6 +275,8 @@ public final class PunktfunkConnection {
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4 case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
case "steamdeck", "steam-deck", "deck": self = .steamDeck case "steamdeck", "steam-deck", "deck": self = .steamDeck
case "steamcontroller", "steam-controller", "steamcon": self = .steamController case "steamcontroller", "steam-controller", "steamcon": self = .steamController
case "steamcontroller2", "steam-controller-2", "steamcon2", "sc2", "ibex":
self = .steamController2
case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge
case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller": case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
self = .switchPro self = .switchPro
+11 -1
View File
@@ -269,6 +269,7 @@ impl PadInfo {
GamepadPref::XboxOne => "Xbox One", GamepadPref::XboxOne => "Xbox One",
GamepadPref::SteamDeck => "Steam Deck", GamepadPref::SteamDeck => "Steam Deck",
GamepadPref::SteamController => "Steam Controller", GamepadPref::SteamController => "Steam Controller",
GamepadPref::SteamController2 => "Steam Controller 2",
GamepadPref::SwitchPro => "Switch Pro", GamepadPref::SwitchPro => "Switch Pro",
_ => "", _ => "",
} }
@@ -1606,7 +1607,8 @@ fn hidout_pad(h: &HidOutput) -> u8 {
HidOutput::Led { pad, .. } HidOutput::Led { pad, .. }
| HidOutput::PlayerLeds { pad, .. } | HidOutput::PlayerLeds { pad, .. }
| HidOutput::Trigger { pad, .. } | HidOutput::Trigger { pad, .. }
| HidOutput::TrackpadHaptic { pad, .. } => *pad, | HidOutput::TrackpadHaptic { pad, .. }
| HidOutput::HidRaw { pad, .. } => *pad,
} }
} }
@@ -1924,5 +1926,13 @@ mod slot_tests {
}), }),
4 4
); );
assert_eq!(
hidout_pad(&HidOutput::HidRaw {
pad: 6,
kind: 0,
data: vec![0x80, 0, 0]
}),
6
);
} }
} }
+23 -6
View File
@@ -584,7 +584,11 @@ pub struct PunktfunkHidOutput {
#[cfg(feature = "quic")] #[cfg(feature = "quic")]
impl PunktfunkHidOutput { impl PunktfunkHidOutput {
fn from_hid(h: &crate::quic::HidOutput) -> PunktfunkHidOutput { /// `None` for a [`HidOutput::HidRaw`](crate::quic::HidOutput) — a raw passthrough report
/// (up to 64 bytes) doesn't fit this struct's 11-byte `effect` buffer, and no C-ABI embedder
/// declares the as-is SC2 kind that would receive one; the pull site skips it rather than
/// truncating it into an unreplayable stub.
fn from_hid(h: &crate::quic::HidOutput) -> Option<PunktfunkHidOutput> {
use crate::quic::HidOutput; use crate::quic::HidOutput;
let mut out = PunktfunkHidOutput { let mut out = PunktfunkHidOutput {
kind: 0, kind: 0,
@@ -635,8 +639,9 @@ impl PunktfunkHidOutput {
out.effect[4..6].copy_from_slice(&count.to_le_bytes()); out.effect[4..6].copy_from_slice(&count.to_le_bytes());
out.effect_len = 6; out.effect_len = 6;
} }
HidOutput::HidRaw { .. } => return None,
} }
out Some(out)
} }
} }
@@ -895,6 +900,12 @@ pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs + /// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands. /// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8; pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
/// hidraw writes (lizard mode, IMU enable, rumble/haptics) come back raw for the physical pad.
/// Steam Input is the consumer (no kernel driver binds the PID). Honored on Linux (UHID);
/// else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9;
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips /// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's /// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's
@@ -958,6 +969,7 @@ const _: () = {
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 == GamepadPref::SteamController2.to_u8() as u32);
// Extended button bits mirror the wire `input::gamepad` constants. // Extended button bits mirror the wire `input::gamepad` constants.
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1); assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2); assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
@@ -2023,10 +2035,15 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
.inner .inner
.next_hidout(std::time::Duration::from_millis(timeout_ms as u64)) .next_hidout(std::time::Duration::from_millis(timeout_ms as u64))
{ {
Ok(h) => { Ok(h) => match PunktfunkHidOutput::from_hid(&h) {
unsafe { *out = PunktfunkHidOutput::from_hid(&h) }; Some(v) => {
PunktfunkStatus::Ok unsafe { *out = v };
} PunktfunkStatus::Ok
}
// A raw as-is passthrough report (no C representation) — report "nothing this
// poll" and let the embedder's poll loop continue; see `from_hid`.
None => PunktfunkStatus::NoFrame,
},
Err(e) => e.status(), Err(e) => e.status(),
} }
}) })
+21 -4
View File
@@ -138,8 +138,9 @@ impl CompositorPref {
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID); /// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single /// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`), appended to /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`). /// `9 = SteamController2`), appended to `Hello`/`Welcome` — older peers simply omit/ignore it (an
/// unknown byte degrades to `Auto`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GamepadPref { pub enum GamepadPref {
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
@@ -172,11 +173,20 @@ pub enum GamepadPref {
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo` ≥ 5.16) — /// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo` ≥ 5.16) —
/// correct Nintendo glyphs + positional layout, gyro/accel, HD rumble back. Needs Linux UHID. /// correct Nintendo glyphs + positional layout, gyro/accel, HD rumble back. Needs Linux UHID.
SwitchPro, SwitchPro,
/// New Steam Controller (2026, Valve "Ibex"/SDL "Triton", wired `28DE:1302`) passed through
/// AS-IS: the host presents a virtual SC2 with the real identity and mirrors the client's raw
/// Triton input reports ([`RichInput::HidReport`](crate::quic::RichInput)); Steam on the host
/// drives it over hidraw exactly like the physical pad (its feature/output writes — lizard
/// mode, IMU enable, rumble/haptics — come back raw on the HID-output plane and land on the
/// real controller). No kernel driver binds the PID (mainline `hid-steam` stops at the Deck),
/// so Steam Input is the consumer. Needs Linux UHID.
SteamController2,
} }
impl GamepadPref { impl GamepadPref {
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`. /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
/// `9 = SteamController2`.
pub const fn to_u8(self) -> u8 { pub const fn to_u8(self) -> u8 {
match self { match self {
GamepadPref::Auto => 0, GamepadPref::Auto => 0,
@@ -188,6 +198,7 @@ impl GamepadPref {
GamepadPref::SteamDeck => 6, GamepadPref::SteamDeck => 6,
GamepadPref::DualSenseEdge => 7, GamepadPref::DualSenseEdge => 7,
GamepadPref::SwitchPro => 8, GamepadPref::SwitchPro => 8,
GamepadPref::SteamController2 => 9,
} }
} }
@@ -203,6 +214,7 @@ impl GamepadPref {
6 => GamepadPref::SteamDeck, 6 => GamepadPref::SteamDeck,
7 => GamepadPref::DualSenseEdge, 7 => GamepadPref::DualSenseEdge,
8 => GamepadPref::SwitchPro, 8 => GamepadPref::SwitchPro,
9 => GamepadPref::SteamController2,
_ => GamepadPref::Auto, _ => GamepadPref::Auto,
} }
} }
@@ -224,12 +236,16 @@ impl GamepadPref {
"switchpro" | "switch-pro" | "switch" | "procontroller" | "pro-controller" => { "switchpro" | "switch-pro" | "switch" | "procontroller" | "pro-controller" => {
GamepadPref::SwitchPro GamepadPref::SwitchPro
} }
"steamcontroller2" | "steam-controller-2" | "steamcon2" | "sc2" | "ibex" => {
GamepadPref::SteamController2
}
_ => return None, _ => return None,
}) })
} }
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`, /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`). /// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`,
/// `"steamcontroller2"`).
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
GamepadPref::Auto => "auto", GamepadPref::Auto => "auto",
@@ -241,6 +257,7 @@ impl GamepadPref {
GamepadPref::SteamDeck => "steamdeck", GamepadPref::SteamDeck => "steamdeck",
GamepadPref::DualSenseEdge => "dualsenseedge", GamepadPref::DualSenseEdge => "dualsenseedge",
GamepadPref::SwitchPro => "switchpro", GamepadPref::SwitchPro => "switchpro",
GamepadPref::SteamController2 => "steamcontroller2",
} }
} }
} }
@@ -161,6 +161,12 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
pub(super) const RICH_TOUCHPAD: u8 = 0x01; pub(super) const RICH_TOUCHPAD: u8 = 0x01;
pub(super) const RICH_MOTION: u8 = 0x02; pub(super) const RICH_MOTION: u8 = 0x02;
pub(super) const RICH_TOUCHPAD_EX: u8 = 0x03; pub(super) const RICH_TOUCHPAD_EX: u8 = 0x03;
pub(super) const RICH_HID_REPORT: u8 = 0x04;
/// Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the
/// 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are
/// 4654 bytes; feature and output reports are at most 64).
pub const HID_REPORT_MAX: usize = 64;
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent): /// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is /// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
@@ -206,6 +212,19 @@ pub enum RichInput {
y: i16, y: i16,
pressure: u16, pressure: u16,
}, },
/// One raw HID input report from a client-captured controller, forwarded verbatim for a
/// host backend that mirrors the physical device as-is (the Steam Controller 2 / Triton
/// passthrough — [`GamepadPref::SteamController2`](crate::config::GamepadPref)). `data[..len]`
/// is exactly what the device produced on its interrupt endpoint / GATT notify, report-id
/// byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …). Best-effort like the rest of
/// the plane: state reports are idempotent snapshots at the device's own rate, so a lost
/// datagram self-heals on the next one. Fixed-size body keeps the type `Copy` on a path that
/// runs at the controller's report rate.
HidReport {
pad: u8,
len: u8,
data: [u8; HID_REPORT_MAX],
},
} }
impl RichInput { impl RichInput {
@@ -245,6 +264,11 @@ impl RichInput {
out.extend_from_slice(&y.to_le_bytes()); out.extend_from_slice(&y.to_le_bytes());
out.extend_from_slice(&pressure.to_le_bytes()); out.extend_from_slice(&pressure.to_le_bytes());
} }
RichInput::HidReport { pad, len, ref data } => {
let len = (len as usize).min(HID_REPORT_MAX);
out.extend_from_slice(&[RICH_HID_REPORT, pad, len as u8]);
out.extend_from_slice(&data[..len]);
}
} }
out out
} }
@@ -279,6 +303,18 @@ impl RichInput {
y: i16::from_le_bytes([b[8], b[9]]), y: i16::from_le_bytes([b[8], b[9]]),
pressure: u16::from_le_bytes([b[10], b[11]]), pressure: u16::from_le_bytes([b[10], b[11]]),
}), }),
RICH_HID_REPORT if b.len() >= 4 => {
// Every byte read below is bounded: `len` is clamped to the fixed body size AND
// to what the buffer actually holds (a torn datagram truncates, never over-reads).
let len = (b[3] as usize).min(HID_REPORT_MAX).min(b.len() - 4);
let mut data = [0u8; HID_REPORT_MAX];
data[..len].copy_from_slice(&b[4..4 + len]);
Some(RichInput::HidReport {
pad: b[2],
len: len as u8,
data,
})
}
_ => None, _ => None,
} }
} }
@@ -288,6 +324,16 @@ const HIDOUT_LED: u8 = 0x01;
const HIDOUT_PLAYER_LEDS: u8 = 0x02; const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03; const HIDOUT_TRIGGER: u8 = 0x03;
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04; const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
const HIDOUT_HID_RAW: u8 = 0x05;
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
/// it on the physical device's interrupt-OUT endpoint / GATT write.
pub const HID_RAW_OUTPUT: u8 = 0;
/// [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with
/// `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client
/// replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write.
pub const HID_RAW_FEATURE: u8 = 1;
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad). /// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram; /// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
@@ -311,6 +357,14 @@ pub enum HidOutput {
period: u16, period: u16,
count: u16, count: u16,
}, },
/// A raw report the host's hidraw consumer (Steam) wrote to an as-is passthrough pad
/// ([`RichInput::HidReport`]'s reverse direction), for the client to replay verbatim on the
/// physical device. `kind` is [`HID_RAW_OUTPUT`] or [`HID_RAW_FEATURE`]; `data` is the full
/// report, id byte first, at most [`HID_REPORT_MAX`] bytes. Best-effort is sound here by the
/// device protocol's own design: Triton rumble is re-sent every ~40 ms against a ~50 ms
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
/// firmware watchdog — a lost datagram heals on the next refresh.
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
} }
impl HidOutput { impl HidOutput {
@@ -339,6 +393,10 @@ impl HidOutput {
out.extend_from_slice(&period.to_le_bytes()); out.extend_from_slice(&period.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes()); out.extend_from_slice(&count.to_le_bytes());
} }
HidOutput::HidRaw { pad, kind, data } => {
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
}
} }
out out
} }
@@ -370,6 +428,12 @@ impl HidOutput {
period: u16::from_le_bytes([b[6], b[7]]), period: u16::from_le_bytes([b[6], b[7]]),
count: u16::from_le_bytes([b[8], b[9]]), count: u16::from_le_bytes([b[8], b[9]]),
}), }),
HIDOUT_HID_RAW if b.len() >= 5 => Some(HidOutput::HidRaw {
pad: b[2],
kind: b[3],
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
}),
_ => None, _ => None,
} }
} }
+56 -2
View File
@@ -342,11 +342,12 @@ fn gamepad_pref_wire_and_names() {
GamepadPref::SteamDeck, GamepadPref::SteamDeck,
GamepadPref::DualSenseEdge, GamepadPref::DualSenseEdge,
GamepadPref::SwitchPro, GamepadPref::SwitchPro,
GamepadPref::SteamController2,
] { ] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p); assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p)); assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
} }
// Every wire byte 0..=8 is assigned, distinct, and pinned (forward-compat with peers // Every wire byte 0..=9 is assigned, distinct, and pinned (forward-compat with peers
// that only know a prefix of the range). // that only know a prefix of the range).
for (v, p) in [ for (v, p) in [
(0, GamepadPref::Auto), (0, GamepadPref::Auto),
@@ -358,12 +359,13 @@ fn gamepad_pref_wire_and_names() {
(6, GamepadPref::SteamDeck), (6, GamepadPref::SteamDeck),
(7, GamepadPref::DualSenseEdge), (7, GamepadPref::DualSenseEdge),
(8, GamepadPref::SwitchPro), (8, GamepadPref::SwitchPro),
(9, GamepadPref::SteamController2),
] { ] {
assert_eq!(p.to_u8(), v); assert_eq!(p.to_u8(), v);
assert_eq!(GamepadPref::from_u8(v), p); assert_eq!(GamepadPref::from_u8(v), p);
} }
// The next unassigned byte degrades to Auto today; assigning it later must update this. // The next unassigned byte degrades to Auto today; assigning it later must update this.
assert_eq!(GamepadPref::from_u8(9), GamepadPref::Auto); assert_eq!(GamepadPref::from_u8(10), GamepadPref::Auto);
// Aliases + unknowns. // Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense)); assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360)); assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
@@ -377,6 +379,14 @@ fn gamepad_pref_wire_and_names() {
GamepadPref::from_name("Switch-Pro"), GamepadPref::from_name("Switch-Pro"),
Some(GamepadPref::SwitchPro) Some(GamepadPref::SwitchPro)
); );
assert_eq!(
GamepadPref::from_name("ibex"),
Some(GamepadPref::SteamController2)
);
assert_eq!(
GamepadPref::from_name("sc2"),
Some(GamepadPref::SteamController2)
);
assert_eq!( assert_eq!(
GamepadPref::from_name("xbox-one"), GamepadPref::from_name("xbox-one"),
Some(GamepadPref::XboxOne) Some(GamepadPref::XboxOne)
@@ -1197,6 +1207,33 @@ fn rich_input_roundtrip() {
assert_eq!(d[0], RICH_INPUT_MAGIC); assert_eq!(d[0], RICH_INPUT_MAGIC);
assert_eq!(RichInput::decode(&d), Some(ev)); assert_eq!(RichInput::decode(&d), Some(ev));
} }
// A raw Triton state report rides the plane verbatim (as-is SC2 passthrough).
let mut data = [0u8; HID_REPORT_MAX];
data[0] = 0x42; // ID_TRITON_CONTROLLER_STATE
for (i, b) in data.iter_mut().enumerate().take(46).skip(1) {
*b = i as u8;
}
let raw = RichInput::HidReport {
pad: 3,
len: 46,
data,
};
let d = raw.encode();
assert_eq!(d.len(), 4 + 46); // tag + kind + pad + len + body — no fixed-array padding
assert_eq!(RichInput::decode(&d), Some(raw));
// A torn HidReport truncates to what arrived rather than over-reading (len clamps).
assert_eq!(
RichInput::decode(&d[..20]),
Some(RichInput::HidReport {
pad: 3,
len: 16,
data: {
let mut t = [0u8; HID_REPORT_MAX];
t[..16].copy_from_slice(&data[..16]);
t
},
})
);
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None. // Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none()); assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
@@ -1230,6 +1267,23 @@ fn hid_output_roundtrip() {
period: 0x5678, period: 0x5678,
count: 9, count: 9,
}, },
// A raw Triton rumble output report (as-is SC2 passthrough, host→client).
HidOutput::HidRaw {
pad: 1,
kind: HID_RAW_OUTPUT,
data: vec![0x80, 0, 0, 0, 0x34, 0x12, 0, 0x78, 0x56, 0],
},
// A raw 64-byte feature report (lizard-off / IMU-enable settings write).
HidOutput::HidRaw {
pad: 0,
kind: HID_RAW_FEATURE,
data: {
let mut f = vec![0u8; HID_REPORT_MAX];
f[0] = 1; // Triton feature reports ride report id 1
f[1] = 0x87; // ID_SET_SETTINGS_VALUES
f
},
},
]; ];
for ev in &cases { for ev in &cases {
let d = ev.encode(); let d = ev.encode();
+11
View File
@@ -527,6 +527,11 @@ pub mod pad_slots;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"] #[path = "inject/linux/steam_controller.rs"]
pub mod steam_controller; pub mod steam_controller;
/// Linux: virtual Steam Controller 2 (Triton, `28DE:1302`) via UHID — as-is raw passthrough of a
/// client-captured physical pad; Steam Input drives the hidraw node (no kernel driver binds it).
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller2.rs"]
pub mod steam_controller2;
/// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel /// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel
/// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis. /// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -565,6 +570,12 @@ pub mod switch_pro;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/proto/switch_proto.rs"] #[path = "inject/proto/switch_proto.rs"]
pub mod switch_proto; pub mod switch_proto;
/// Transport-independent Steam Controller 2 (Triton) contract: descriptor, SDL-documented report
/// layout, the typed fallback serializer, and the rumble-output parser. Linux-only consumer today
/// ([`steam_controller2`]).
#[cfg(target_os = "linux")]
#[path = "inject/proto/triton_proto.rs"]
pub mod triton_proto;
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame /// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only /// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12). /// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
@@ -0,0 +1,353 @@
//! Virtual **Steam Controller 2** (Triton) via UHID — the as-is passthrough backend
//! ([`GamepadPref::SteamController2`](punktfunk_core::config::GamepadPref)). The
//! transport-independent contract (descriptor, report ids, the typed fallback serializer, the
//! rumble parser) lives in [`super::triton_proto`]; this module is the `/dev/uhid` plumbing.
//!
//! Deltas vs the Deck backend ([`super::steam_controller`]):
//!
//! 1. **No kernel driver.** Mainline `hid-steam` doesn't bind `28DE:1302`, so the device gets
//! `hid-generic` + a hidraw node and NO evdev — Steam Input (hidapi over hidraw) is the only
//! consumer, exactly as it is for the physical pad. No `gamepad_mode` machinery applies.
//! 2. **Raw mirroring.** Input reports arrive verbatim from the client
//! ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)) and are written unchanged;
//! everything Steam writes back (SET_REPORT features, OUTPUT haptics) is acked and forwarded
//! raw for replay on the physical controller.
//! 3. **UHID-only for now.** Steam Input historically ignores UHID devices for *promotion*
//! (`Interface: -1`; the Deck path grew usbip/gadget transports for this). Whether Steam's
//! Triton support accepts a UHID hidraw is unverified on-glass — the creation log flags it,
//! and a usbip transport (needs the physical pad's captured USB descriptors) is the known
//! follow-up if it doesn't.
use super::triton_proto::{
parse_triton_rumble, serialize_triton_state, strip_report_prefix, TritonState, TRITON_RDESC,
TRITON_STATE_LEN, TRITON_VENDOR, TRITON_WIRED_PRODUCT,
};
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372;
const BUS_USB: u16 = 0x03;
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
}
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
pub struct TritonPad {
fd: File,
/// Synth-mode sequence counter (the raw path carries the physical pad's own seq).
seq: u8,
/// Raw reports Steam wrote since the last service pass, kind-tagged for the 0xCD plane.
pending_raw: Vec<(u8, Vec<u8>)>,
}
impl TritonPad {
pub fn open(index: u8) -> Result<TritonPad> {
let fd = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(UHID_PATH)
.with_context(|| {
format!("open {UHID_PATH} (is the uhid udev rule installed + are you in 'input'?)")
})?;
let mut pad = TritonPad {
fd,
seq: 0,
pending_raw: Vec::new(),
};
pad.send_create2(index).context("UHID_CREATE2 Triton pad")?;
Ok(pad)
}
fn send_create2(&mut self, index: u8) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
// The physical pad's USB product string is "Steam Controller"; keep the punktfunk prefix
// convention every virtual pad uses (Steam matches on VID/PID, not the name).
put_cstr(
&mut ev,
4,
128,
&format!("Punktfunk Steam Controller 2 {index}"),
); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/triton/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-triton-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(TRITON_RDESC.len() as u16).to_ne_bytes()); // rd_size
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
ev[264..268].copy_from_slice(&TRITON_VENDOR.to_ne_bytes());
ev[268..272].copy_from_slice(&TRITON_WIRED_PRODUCT.to_ne_bytes());
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
ev[280..280 + TRITON_RDESC.len()].copy_from_slice(TRITON_RDESC);
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
Ok(())
}
/// Mirror one report out: the client's raw bytes verbatim in as-is mode, else a synthesized
/// minimal `0x42` state report from the typed fallback fields.
pub fn write_state(&mut self, st: &TritonState) -> Result<()> {
if st.raw_len > 0 {
let len = (st.raw_len as usize).min(st.raw.len());
return self.write_input(&st.raw[..len]);
}
self.seq = self.seq.wrapping_add(1);
let mut r = [0u8; TRITON_STATE_LEN];
serialize_triton_state(&mut r, st, self.seq);
self.write_input(&r)
}
fn write_input(&mut self, data: &[u8]) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
ev[4..6].copy_from_slice(&(data.len() as u16).to_ne_bytes()); // input2.size
ev[6..6 + data.len()].copy_from_slice(data); // input2.data
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
Ok(())
}
/// Service the device, non-blocking: ack SET_REPORTs (a stalled ack blocks the writer ~5 s),
/// answer GET_REPORTs (best-effort canned reply — the query/answer feature dance can't
/// round-trip to the physical pad synchronously), and queue every report Steam wrote for raw
/// forwarding. Returns the rumble level if a `0x80` output report was seen this pass.
pub fn service(&mut self) -> Option<(u16, u16)> {
let mut rumble = None;
let mut ev = [0u8; UHID_EVENT_SIZE];
while let Ok(n) = self.fd.read(&mut ev) {
if n < UHID_EVENT_SIZE {
break;
}
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
UHID_OUTPUT => {
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
let rep = strip_report_prefix(&ev[4..end]);
if let Some(r) = parse_triton_rumble(rep) {
rumble = Some(r);
}
self.queue_raw(HID_RAW_OUTPUT, rep);
}
UHID_SET_REPORT => {
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
// uhid_set_report: id u32, rnum u8, rtype u8, size u16, data — data at ev[12..].
let size = u16::from_ne_bytes([ev[10], ev[11]]) as usize;
let end = (12 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(UHID_EVENT_SIZE);
let rep = strip_report_prefix(&ev[12..end]);
if let Some(r) = parse_triton_rumble(rep) {
rumble = Some(r); // some stacks send haptics on the feature path
}
self.queue_raw(HID_RAW_FEATURE, rep);
let _ = self.reply_set_report(id);
}
UHID_GET_REPORT => {
// Steam's attribute/serial reads can't reach the physical pad synchronously;
// answer with a plausible Triton-shaped string-attribute reply (the same
// canned-reply approach the virtual Deck ships). Logged for on-glass tuning.
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
tracing::debug!(
rnum = ev[8],
"virtual SC2: GET_REPORT — canned serial reply"
);
let _ = self.reply_get_report(id, &triton_serial_reply("PUNKTFUNK02"));
}
_ => {} // Start/Stop/Open/Close — ignore
}
}
rumble
}
/// Queue a raw report for the 0xCD plane, capped so a hidraw client gone haywire can't grow
/// the queue unboundedly between pumps (newest wins — these are level-styled commands).
fn queue_raw(&mut self, kind: u8, data: &[u8]) {
if data.is_empty() {
return;
}
if self.pending_raw.len() >= 32 {
self.pending_raw.remove(0);
}
self.pending_raw.push((kind, data.to_vec()));
}
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
ev[4..8].copy_from_slice(&id.to_ne_bytes());
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
ev[12..12 + data.len()].copy_from_slice(data);
self.fd.write_all(&ev).context("UHID_GET_REPORT_REPLY")?;
Ok(())
}
fn reply_set_report(&mut self, id: u32) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
ev[4..8].copy_from_slice(&id.to_ne_bytes());
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
self.fd.write_all(&ev).context("UHID_SET_REPORT_REPLY")?;
Ok(())
}
}
/// The Valve feature GET reply shape (`[report-id 1][ID_GET_STRING_ATTRIBUTE][len][unit-serial]
/// [ascii…]`), Triton-flavored: feature reports ride report id 1 on this device (SDL sends
/// `buffer[0] = 1`), unlike the Deck's id-0 path.
fn triton_serial_reply(serial: &str) -> [u8; 64] {
const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
let mut buf = [0u8; 64];
let bytes = serial.as_bytes();
let len = bytes.len().clamp(1, 21);
buf[0] = 0x01; // feature report id
buf[1] = ID_GET_STRING_ATTRIBUTE;
buf[2] = len as u8;
buf[3] = ATTRIB_STR_UNIT_SERIAL;
buf[4..4 + len].copy_from_slice(&bytes[..len]);
buf
}
impl Drop for TritonPad {
fn drop(&mut self) {
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
let _ = self.fd.write_all(&ev);
}
}
/// The Triton-specific half of the shared stateful manager (see [`PadProto`]): raw mirroring
/// with the typed fallback, and the raw-forwarding service pass.
#[derive(Default)]
pub struct TritonProto;
impl PadProto for TritonProto {
type Pad = TritonPad;
type State = TritonState;
const LABEL: &'static str = "Steam Controller 2";
const DEVICE: &'static str = "Steam Controller 2";
const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<TritonPad> {
let p = TritonPad::open(idx)?;
tracing::info!(
index = idx,
"virtual Steam Controller 2 created (UHID 28DE:1302, as-is passthrough — hidraw \
only, no kernel driver; if Steam doesn't list it, UHID promotion is the suspect \
and a usbip transport is the follow-up)"
);
Ok(p)
}
fn neutral(&self) -> TritonState {
TritonState::neutral()
}
/// Typed fallback merge. Once raw reports flow (`raw_len > 0`) the frame only refreshes the
/// typed fields for diagnostics — `write_state` keeps mirroring the raw report.
fn merge_frame(
&self,
prev: &TritonState,
f: &crate::gamestream::gamepad::GamepadFrame,
) -> TritonState {
let mut s = TritonState::from_gamepad(
f.buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
// As-is mode is sticky: a typed frame between two raw reports must not flap the pad back
// to synth mode (the client sends BOTH planes — typed keeps the degrade paths alive).
s.raw = prev.raw;
s.raw_len = prev.raw_len;
s
}
fn apply_rich(&self, st: &mut TritonState, rich: RichInput) {
if let RichInput::HidReport { len, data, .. } = rich {
let len = (len as usize).min(data.len()).min(st.raw.len());
if len == 0 {
return;
}
st.raw[..len].copy_from_slice(&data[..len]);
st.raw_len = len as u8;
}
// Touchpad/Motion/TouchpadEx: nothing to fold — the raw feed carries pads + IMU natively,
// and the synth fallback has no surface for them.
}
fn write_state(&self, pad: &mut TritonPad, st: &TritonState) {
let _ = pad.write_state(st);
}
/// Ack + queue Steam's writes, then hand them to the pump as raw 0xCD events; rumble ALSO
/// rides the universal 0xCA plane (deduped) so the client's phone-mirror path keeps working.
fn service(&self, pad: &mut TritonPad, idx: u8) -> PadFeedback {
let rumble = pad.service();
let hidout = std::mem::take(&mut pad.pending_raw)
.into_iter()
.map(|(kind, data)| HidOutput::HidRaw {
pad: idx,
kind,
data,
})
.collect();
PadFeedback { rumble, hidout }
}
}
/// All virtual Steam Controller 2 pads of a session — `PUNKTFUNK_GAMEPAD=steamcontroller2`
/// (aliases `sc2`/`ibex`), or the per-pad kind an Android client declares for a captured
/// physical pad.
pub type Triton2Manager = UhidManager<TritonProto>;
#[cfg(test)]
mod tests {
use super::*;
/// On-box smoke: the virtual SC2 must create a hidraw node under `hid-generic` (no evdev —
/// nothing binds the PID) carrying the Valve identity, mirror a raw state report verbatim,
/// and tear down on drop. `#[ignore]`d in CI (touches `/dev/uhid`); run on a Linux box:
/// `cargo test -p punktfunk-host -- --ignored triton`.
#[test]
#[ignore = "creates a real /dev/uhid device; needs the input group"]
fn triton_backend_creates_hidraw_and_mirrors_raw() {
let mut pad = TritonPad::open(0).expect("open TritonPad (/dev/uhid + input group?)");
// Mirror one raw report (as the client would forward it).
let mut st = TritonState::neutral();
let raw: &[u8] = &[0x42, 1, 0x01, 0, 0, 0, 0xFF, 0x7F]; // A held, LT full — truncated is fine
st.raw[..raw.len()].copy_from_slice(raw);
st.raw_len = raw.len() as u8;
for _ in 0..50 {
let _ = pad.service();
pad.write_state(&st).expect("write_state");
std::thread::sleep(std::time::Duration::from_millis(4));
}
// The device exists with the Valve identity (hidraw only; /proc/bus/input has no entry).
let found = std::fs::read_dir("/sys/bus/hid/devices")
.map(|d| {
d.flatten()
.any(|e| e.file_name().to_string_lossy().contains(":28DE:1302"))
})
.unwrap_or(false);
assert!(found, "virtual 28DE:1302 HID device not created");
drop(pad);
}
}
@@ -401,6 +401,8 @@ impl DsState {
}; };
self.touch_click[slot] = click; self.touch_click[slot] = click;
} }
// Raw as-is passthrough reports belong to the Triton backend, never a DS state.
RichInput::HidReport { .. } => {}
} }
} }
@@ -583,6 +585,11 @@ impl HidoutDedup {
} }
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires. // One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true, HidOutput::TrackpadHaptic { .. } => true,
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
// silence the motors / re-enable lizard mode on the real controller.
HidOutput::HidRaw { .. } => true,
} }
} }
} }
@@ -294,6 +294,8 @@ impl SteamState {
self.rpad_y = flip_y(y); self.rpad_y = flip_y(y);
} }
} }
// Raw as-is passthrough reports belong to the Triton backend, never a Deck/SC state.
RichInput::HidReport { .. } => {}
} }
} }
} }
@@ -0,0 +1,306 @@
//! Transport-independent contract for the virtual **Steam Controller 2** (2026, Valve "Ibex" /
//! SDL "Triton", wired `28DE:1302`) — the as-is passthrough sibling of [`super::steam_proto`].
//!
//! Unlike the Deck/classic-SC backends, this device is NOT re-synthesized from typed wire state:
//! the client captures the physical controller (USB Puck / wired / BLE) and forwards its raw
//! input reports verbatim ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)); the host
//! mirrors them out unchanged, and everything the host's hidraw consumer writes back (Steam's
//! lizard-off / IMU-enable feature reports, `0x80` rumble output reports) is forwarded raw to the
//! client for replay on the real controller ([`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)).
//! Mainline `hid-steam` does not bind this PID, so no kernel evdev exists — **Steam Input is the
//! consumer**, driving the hidraw node exactly as it drives the physical pad.
//!
//! Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
//! (Valve-maintained). Input report ids `0x42`/`0x45` (`TritonMTUNoQuat_t`, 46 bytes with id),
//! `0x47` (adds a trackpad timestamp), `0x43` battery, `0x46`/`0x79` wireless status. Feature
//! reports are 64 bytes on report id `1`; haptics are OUTPUT reports `0x80..=0x85`.
//!
//! A typed **fallback synthesizer** is kept for the degraded case (a client that declared the
//! kind but sends no raw feed): buttons/sticks/triggers from the ordinary gamepad plane are
//! serialized into a minimal `0x42` state report. The first raw report permanently switches the
//! pad to as-is mode.
use punktfunk_core::input::gamepad as gs;
/// Valve vendor id (same as [`super::steam_proto::STEAM_VENDOR`], repeated to keep this module
/// self-contained).
pub const TRITON_VENDOR: u32 = 0x28DE;
/// The wired Steam Controller 2 identity the virtual pad presents. The BLE (`0x1303`) and Puck
/// dongle (`0x1304`/`0x1305`) identities are client-side transports only — Steam treats the wired
/// PID as the canonical controller.
pub const TRITON_WIRED_PRODUCT: u32 = 0x1302;
/// Triton input-report ids (`ETritonReportIDTypes`, SDL `controller_structs.h`).
pub const ID_TRITON_CONTROLLER_STATE: u8 = 0x42;
pub const ID_TRITON_BATTERY_STATUS: u8 = 0x43;
pub const ID_TRITON_CONTROLLER_STATE_BLE: u8 = 0x45;
pub const ID_TRITON_CONTROLLER_STATE_TIMESTAMP: u8 = 0x47;
/// Haptic OUTPUT report ids (`ID_OUT_REPORT_*`). Only rumble is parsed host-side (for the
/// universal 0xCA plane); every output report is forwarded raw regardless.
pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80;
/// Fixed report size: 64-byte feature reports, input reports at most 64 (state is 46/54).
pub const TRITON_REPORT_LEN: usize = 64;
/// The `TritonMTUNoQuat_t` state payload (46 bytes with the leading report id).
pub const TRITON_STATE_LEN: usize = 46;
/// Minimal vendor-defined HID report descriptor, mirroring [`super::steam_proto::STEAMDECK_RDESC`]
/// with an added OUTPUT item: the Triton receives haptics as output reports (`SDL_hid_write`),
/// not feature-only like the Deck, so hidapi consumers expect a writable interrupt-OUT-style
/// report to exist. All items unnumbered 64-byte — the raw bytes we mirror carry the Valve
/// report-type byte first, exactly like the physical device's stream.
#[rustfmt::skip]
pub const TRITON_RDESC: &[u8] = &[
0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00)
0x09, 0x01, // Usage (0x01)
0xA1, 0x01, // Collection (Application)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8 bits)
0x95, 0x40, // Report Count (64)
0x09, 0x01, // Usage (0x01)
0x81, 0x02, // Input (Data,Var,Abs) — the state/battery/wireless report stream
0x09, 0x01, // Usage (0x01)
0x95, 0x40, // Report Count (64)
0x91, 0x02, // Output (Data,Var,Abs) — haptic commands (0x80 rumble, 0x81 pulse, …)
0x09, 0x01, // Usage (0x01)
0x95, 0x40, // Report Count (64)
0xB1, 0x02, // Feature (Data,Var,Abs) — settings/attributes (report id 1 on the wire)
0xC0, // End Collection
];
/// Triton button bits in the state report's `buttons` u32 — transcribed verbatim from SDL's
/// `TritonButtons`. Only the bits the typed fallback synthesizes are named; the raw path carries
/// whatever the physical pad set.
pub mod tbtn {
pub const A: u32 = 0x0000_0001;
pub const B: u32 = 0x0000_0002;
pub const X: u32 = 0x0000_0004;
pub const Y: u32 = 0x0000_0008;
pub const QAM: u32 = 0x0000_0010;
pub const R3: u32 = 0x0000_0020;
pub const VIEW: u32 = 0x0000_0040;
pub const R4: u32 = 0x0000_0080;
pub const R5: u32 = 0x0000_0100;
pub const RB: u32 = 0x0000_0200;
pub const DPAD_DOWN: u32 = 0x0000_0400;
pub const DPAD_RIGHT: u32 = 0x0000_0800;
pub const DPAD_LEFT: u32 = 0x0000_1000;
pub const DPAD_UP: u32 = 0x0000_2000;
pub const MENU: u32 = 0x0000_4000;
pub const L3: u32 = 0x0000_8000;
pub const STEAM: u32 = 0x0001_0000;
pub const L4: u32 = 0x0002_0000;
pub const L5: u32 = 0x0004_0000;
pub const LB: u32 = 0x0008_0000;
pub const RPAD_TOUCH: u32 = 0x0020_0000;
pub const RPAD_CLICK: u32 = 0x0040_0000;
pub const RT_CLICK: u32 = 0x0080_0000;
pub const LPAD_TOUCH: u32 = 0x0200_0000;
pub const LPAD_CLICK: u32 = 0x0400_0000;
pub const LT_CLICK: u32 = 0x0800_0000;
}
/// One virtual Triton pad's report state. In as-is mode (`raw_len > 0`) the raw report IS the
/// state; the typed fields only feed the fallback synthesizer until the first raw report lands.
#[derive(Clone, Copy)]
pub struct TritonState {
/// The last raw report the client forwarded (report-id byte first); `raw_len == 0` until the
/// first one arrives, after which the typed fields below stop mattering.
pub raw: [u8; TRITON_REPORT_LEN],
pub raw_len: u8,
/// Typed fallback fields (Triton bit layout / raw axis units), from the ordinary wire plane.
pub buttons: u32,
pub lt: u16,
pub rt: u16,
pub lx: i16,
pub ly: i16,
pub rx: i16,
pub ry: i16,
}
impl TritonState {
pub fn neutral() -> TritonState {
TritonState {
raw: [0u8; TRITON_REPORT_LEN],
raw_len: 0,
buttons: 0,
lt: 0,
rt: 0,
lx: 0,
ly: 0,
rx: 0,
ry: 0,
}
}
/// Typed fallback: fold one wire button/stick frame into Triton fields. Mapping follows the
/// Deck backend's conventions (PADDLE1/2/3/4 = R4/L4/R5/L5, MISC1 = QAM, the DualSense
/// touchpad-click wire bit = right-pad click); sticks are already the device convention
/// (+y up), triggers scale 0..255 → 0..32767.
pub fn from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> TritonState {
let on = |bit: u32| buttons & bit != 0;
let trig = |v: u8| ((v as u32 * 32767) / 255) as u16;
let mut b = 0u32;
let set = |b: &mut u32, on: bool, m: u32| {
if on {
*b |= m;
}
};
set(&mut b, on(gs::BTN_A), tbtn::A);
set(&mut b, on(gs::BTN_B), tbtn::B);
set(&mut b, on(gs::BTN_X), tbtn::X);
set(&mut b, on(gs::BTN_Y), tbtn::Y);
set(&mut b, on(gs::BTN_LB), tbtn::LB);
set(&mut b, on(gs::BTN_RB), tbtn::RB);
set(&mut b, on(gs::BTN_BACK), tbtn::VIEW);
set(&mut b, on(gs::BTN_START), tbtn::MENU);
set(&mut b, on(gs::BTN_GUIDE), tbtn::STEAM);
set(&mut b, on(gs::BTN_LS_CLICK), tbtn::L3);
set(&mut b, on(gs::BTN_RS_CLICK), tbtn::R3);
set(&mut b, on(gs::BTN_DPAD_UP), tbtn::DPAD_UP);
set(&mut b, on(gs::BTN_DPAD_DOWN), tbtn::DPAD_DOWN);
set(&mut b, on(gs::BTN_DPAD_LEFT), tbtn::DPAD_LEFT);
set(&mut b, on(gs::BTN_DPAD_RIGHT), tbtn::DPAD_RIGHT);
set(&mut b, on(gs::BTN_TOUCHPAD), tbtn::RPAD_CLICK);
set(&mut b, on(gs::BTN_PADDLE1), tbtn::R4);
set(&mut b, on(gs::BTN_PADDLE2), tbtn::L4);
set(&mut b, on(gs::BTN_PADDLE3), tbtn::R5);
set(&mut b, on(gs::BTN_PADDLE4), tbtn::L5);
set(&mut b, on(gs::BTN_MISC1), tbtn::QAM);
// "Fully pressed" digital shadow of the analog triggers (the physical pad's own
// threshold is a hard pull, not first-contact).
set(&mut b, lt >= 240, tbtn::LT_CLICK);
set(&mut b, rt >= 240, tbtn::RT_CLICK);
TritonState {
raw: [0u8; TRITON_REPORT_LEN],
raw_len: 0,
buttons: b,
lt: trig(lt),
rt: trig(rt),
lx,
ly,
rx,
ry,
}
}
}
/// Serialize the typed fallback state into a minimal `0x42` `TritonMTUNoQuat_t` report:
/// `[0x42][seq u8][buttons u32][trigL i16][trigR i16][sticks i16×4][lpad x/y + pressure]
/// [rpad x/y + pressure][imu ts u32 + accel i16×3 + gyro i16×3]` — pads and IMU stay zero
/// (no raw feed = no trackpad/motion source; Steam only sees IMU data after enabling
/// `SETTING_IMU_MODE` on a real feed anyway).
pub fn serialize_triton_state(buf: &mut [u8; TRITON_STATE_LEN], st: &TritonState, seq: u8) {
buf.fill(0);
buf[0] = ID_TRITON_CONTROLLER_STATE;
buf[1] = seq;
buf[2..6].copy_from_slice(&st.buttons.to_le_bytes());
buf[6..8].copy_from_slice(&(st.lt as i16).to_le_bytes());
buf[8..10].copy_from_slice(&(st.rt as i16).to_le_bytes());
buf[10..12].copy_from_slice(&st.lx.to_le_bytes());
buf[12..14].copy_from_slice(&st.ly.to_le_bytes());
buf[14..16].copy_from_slice(&st.rx.to_le_bytes());
buf[16..18].copy_from_slice(&st.ry.to_le_bytes());
// [18..30] left/right pad + pressures stay zero; [30..46] IMU stays zero.
}
/// One service pass's extracted feedback: the raw reports to forward (kind-tagged for
/// [`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)) plus the rumble level parsed out of a
/// `0x80` report for the universal 0xCA plane (drives the phone-mirror path on clients whose
/// physical pad already gets the raw report).
#[derive(Default)]
pub struct TritonFeedback {
/// `(low, high)` — `left.speed`/`right.speed` of the last rumble output report seen.
pub rumble: Option<(u16, u16)>,
/// Raw reports to forward: `(kind, bytes)` with kind = `HID_RAW_OUTPUT`/`HID_RAW_FEATURE`.
pub raw: Vec<(u8, Vec<u8>)>,
}
/// Parse a Triton haptic-rumble OUTPUT report (`MsgHapticRumble`, 10 bytes with id):
/// `[0x80][type u8][intensity u16][left.speed u16][left.gain i8][right.speed u16][right.gain i8]`.
/// Returns `(left_speed, right_speed)` as `(low, high)`.
pub fn parse_triton_rumble(data: &[u8]) -> Option<(u16, u16)> {
if data.len() < 10 || data[0] != ID_OUT_REPORT_HAPTIC_RUMBLE {
return None;
}
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
Some((le(4), le(7)))
}
/// Strip the hidraw unnumbered-report `0x00` prefix if present: Triton report/command ids are all
/// non-zero (`0x42+` input, `0x80+` output, `1` feature), so a leading zero can only be the
/// synthetic report-id byte hidraw prepends on this unnumbered virtual descriptor.
pub fn strip_report_prefix(data: &[u8]) -> &[u8] {
match data {
[0, rest @ ..] if !rest.is_empty() => rest,
d => d,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The typed fallback lands the canonical wire mapping on the SDL-documented bit positions
/// and byte offsets.
#[test]
fn fallback_state_serializes_sdl_layout() {
let st = TritonState::from_gamepad(
gs::BTN_A | gs::BTN_START | gs::BTN_PADDLE1 | gs::BTN_MISC1,
1000,
-2000,
3000,
-32768,
255,
0,
);
assert_eq!(
st.buttons,
tbtn::A | tbtn::MENU | tbtn::R4 | tbtn::QAM | tbtn::LT_CLICK
);
assert_eq!(st.lt, 32767); // exact full-scale, not the *128 approximation
let mut r = [0u8; TRITON_STATE_LEN];
serialize_triton_state(&mut r, &st, 7);
assert_eq!(r[0], ID_TRITON_CONTROLLER_STATE);
assert_eq!(r[1], 7);
assert_eq!(u32::from_le_bytes([r[2], r[3], r[4], r[5]]), st.buttons);
assert_eq!(i16::from_le_bytes([r[6], r[7]]), 32767); // sTriggerLeft
assert_eq!(i16::from_le_bytes([r[10], r[11]]), 1000); // sLeftStickX
assert_eq!(i16::from_le_bytes([r[16], r[17]]), -32768); // sRightStickY
assert!(r[18..].iter().all(|&b| b == 0)); // pads + IMU zero
}
/// A rumble output report parses to `(left_speed, right_speed)`; other ids don't.
#[test]
fn rumble_output_report_parses() {
// [0x80, type, intensity(2), left.speed(2), left.gain, right.speed(2), right.gain]
let mut d = [0u8; 10];
d[0] = ID_OUT_REPORT_HAPTIC_RUMBLE;
d[4..6].copy_from_slice(&0x1234u16.to_le_bytes());
d[7..9].copy_from_slice(&0x5678u16.to_le_bytes());
assert_eq!(parse_triton_rumble(&d), Some((0x1234, 0x5678)));
d[0] = 0x81; // haptic pulse — not rumble
assert_eq!(parse_triton_rumble(&d), None);
assert_eq!(parse_triton_rumble(&d[..8]), None); // short
}
/// The hidraw `0x00` unnumbered prefix strips; genuine command bytes survive.
#[test]
fn report_prefix_strips_only_leading_zero() {
assert_eq!(strip_report_prefix(&[0x00, 0x80, 1, 2]), &[0x80, 1, 2]);
assert_eq!(strip_report_prefix(&[0x80, 1, 2]), &[0x80, 1, 2]);
assert_eq!(strip_report_prefix(&[0x01, 0x87]), &[0x01, 0x87]); // feature id 1 kept
assert_eq!(strip_report_prefix(&[0x00]), &[0x00]); // lone zero: nothing to strip to
}
}
@@ -147,7 +147,8 @@ impl<B: PadProto> UhidManager<B> {
let idx = match rich { let idx = match rich {
RichInput::Touchpad { pad, .. } RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. } | RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize, | RichInput::TouchpadEx { pad, .. }
| RichInput::HidReport { pad, .. } => pad as usize,
}; };
if idx >= MAX_PADS || self.slots.get(idx).is_none() { if idx >= MAX_PADS || self.slots.get(idx).is_none() {
return; return;
+46 -2
View File
@@ -1869,6 +1869,8 @@ struct Pads {
switchpro: Option<crate::inject::switch_pro::SwitchProManager>, switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>, steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
#[cfg(target_os = "linux")]
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>, dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -1906,6 +1908,8 @@ impl Pads {
switchpro: None, switchpro: None,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
steamctrl: None, steamctrl: None,
#[cfg(target_os = "linux")]
steamctrl2: None,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_win: None, dualsense_win: None,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -1989,6 +1993,11 @@ impl Pads {
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new) .get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
.handle(ev), .handle(ev),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
GamepadPref::SteamController2 => self
.steamctrl2
.get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::XboxOne => self GamepadPref::XboxOne => self
.xboxone .xboxone
.get_or_insert_with(|| { .get_or_insert_with(|| {
@@ -2036,7 +2045,8 @@ impl Pads {
let idx = match rich { let idx = match rich {
RichInput::Touchpad { pad, .. } RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. } | RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize, | RichInput::TouchpadEx { pad, .. }
| RichInput::HidReport { pad, .. } => pad as usize,
}; };
// Route to the manager that actually owns the device (falling back to the declared kind // Route to the manager that actually owns the device (falling back to the declared kind
// before the first frame builds it), so a pad's touchpad/motion never lands on the wrong // before the first frame builds it), so a pad's touchpad/motion never lands on the wrong
@@ -2085,6 +2095,12 @@ impl Pads {
m.apply_rich(rich) m.apply_rich(rich)
} }
} }
#[cfg(target_os = "linux")]
GamepadPref::SteamController2 => {
if let Some(m) = &mut self.steamctrl2 {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
GamepadPref::DualSense => { GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense_win { if let Some(m) = &mut self.dualsense_win {
@@ -2148,6 +2164,9 @@ impl Pads {
if let Some(m) = &mut self.steamctrl { if let Some(m) = &mut self.steamctrl {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
if let Some(m) = &mut self.steamctrl2 {
m.pump(&mut rumble, &mut hidout);
}
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -2192,6 +2211,9 @@ impl Pads {
if let Some(m) = &mut self.steamctrl { if let Some(m) = &mut self.steamctrl {
m.heartbeat(gap); m.heartbeat(gap);
} }
if let Some(m) = &mut self.steamctrl2 {
m.heartbeat(gap);
}
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -2903,6 +2925,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional // Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there. // layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro, GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
// New Steam Controller (2026, `28DE:1302`): passed through as-is on Linux — the Triton
// UHID backend mirrors the client's raw reports under the real identity and Steam on
// the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the
// consumer). No Windows backend; folds to Xbox360 there.
GamepadPref::SteamController2 if linux => GamepadPref::SteamController2,
_ => GamepadPref::Xbox360, _ => GamepadPref::Xbox360,
} }
} }
@@ -2920,6 +2947,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
| GamepadPref::DualShock4 | GamepadPref::DualShock4
| GamepadPref::SteamDeck | GamepadPref::SteamDeck
| GamepadPref::SteamController | GamepadPref::SteamController
| GamepadPref::SteamController2
| GamepadPref::SwitchPro | GamepadPref::SwitchPro
); );
if needs_uhid if needs_uhid
@@ -2985,7 +3013,7 @@ fn physical_steam_controller_present() -> bool {
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
if !matches!( if !matches!(
chosen, chosen,
GamepadPref::SteamDeck | GamepadPref::SteamController GamepadPref::SteamDeck | GamepadPref::SteamController | GamepadPref::SteamController2
) { ) {
return chosen; return chosen;
} }
@@ -5622,6 +5650,22 @@ mod tests {
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro); assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360); assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360); assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
// New Steam Controller (as-is Triton passthrough): native on Linux (UHID, Steam-driven);
// Xbox360 on Windows and elsewhere.
assert_eq!(
pick_gamepad(SteamController2, None, true, false),
SteamController2
);
assert_eq!(
pick_gamepad(Auto, Some("sc2"), true, false),
SteamController2
);
assert_eq!(
pick_gamepad(Auto, Some("ibex"), true, false),
SteamController2
);
assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360);
} }
#[test] #[test]
+1 -1
View File
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. | | `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. | | `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
## Audio / microphone ## Audio / microphone
+28
View File
@@ -134,6 +134,13 @@
// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands. // positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
#define PUNKTFUNK_GAMEPAD_SWITCHPRO 8 #define PUNKTFUNK_GAMEPAD_SWITCHPRO 8
// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
// hidraw writes (lizard mode, IMU enable, rumble/haptics) come back raw for the physical pad.
// Steam Input is the consumer (no kernel driver binds the PID). Honored on Linux (UHID);
// else folds to X-Box 360.
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 9
// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips // Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's // (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`. // `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
@@ -368,6 +375,27 @@
#define RUMBLE_V2_LEN 10 #define RUMBLE_V2_LEN 10
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the
// 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are
// 4654 bytes; feature and output reports are at most 64).
#define HID_REPORT_MAX 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
// it on the physical device's interrupt-OUT endpoint / GATT write.
#define HID_RAW_OUTPUT 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with
// `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client
// replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write.
#define HID_RAW_FEATURE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI; // HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`]. // see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].