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
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:
@@ -27,6 +27,10 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Gamepad rumble feedback. -->
|
||||
<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
|
||||
devices without a gamepad. -->
|
||||
@@ -40,6 +44,10 @@
|
||||
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.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.
|
||||
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.
|
||||
*/
|
||||
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. */
|
||||
@@ -151,6 +160,7 @@ class SettingsStore(context: Context) {
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -172,6 +182,7 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -208,6 +219,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
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. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
|
||||
@@ -426,6 +426,14 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
||||
checked = s.rumbleOnPhone,
|
||||
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
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
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.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
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
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
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 {
|
||||
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
|
||||
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
|
||||
activity?.gamepadRouter = null
|
||||
activity?.streamHandle = 0L
|
||||
|
||||
@@ -36,6 +36,16 @@ object Gamepad {
|
||||
const val BTN_X = 0x4000
|
||||
const val BTN_Y = 0x8000
|
||||
|
||||
// Extended bits (Moonlight `buttonFlags2 << 16` namespace — `input.rs::gamepad`): the four
|
||||
// back grips (Steam L4/L5/R4/R5 ≙ Elite P1–P4), touchpad click, and the misc/QAM button.
|
||||
// Android's standard InputDevice path never produces these; the SC2 capture link does.
|
||||
const val BTN_PADDLE1 = 0x10000
|
||||
const val BTN_PADDLE2 = 0x20000
|
||||
const val BTN_PADDLE3 = 0x40000
|
||||
const val BTN_PADDLE4 = 0x80000
|
||||
const val BTN_TOUCHPAD = 0x100000
|
||||
const val BTN_MISC1 = 0x200000
|
||||
|
||||
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
|
||||
const val AXIS_LS_X = 0
|
||||
const val AXIS_LS_Y = 1
|
||||
@@ -54,6 +64,7 @@ object Gamepad {
|
||||
const val PREF_STEAMDECK = 6
|
||||
const val PREF_DUALSENSEEDGE = 7
|
||||
const val PREF_SWITCHPRO = 8
|
||||
const val PREF_STEAMCONTROLLER2 = 9
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
|
||||
@@ -51,6 +51,7 @@ class GamepadFeedback(
|
||||
const val TAG_LED: Byte = 0x01
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
||||
// A new host renews far below this, so it never actually holds this long there.
|
||||
const val LEGACY_RUMBLE_MS = 60_000L
|
||||
@@ -112,7 +113,8 @@ class GamepadFeedback(
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
val buf = ByteBuffer.allocateDirect(64)
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
@@ -331,10 +333,32 @@ class GamepadFeedback(
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
}
|
||||
TAG_HID_RAW -> {
|
||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||
// [kind: 0=output, 1=feature][report bytes, id first]. Handed to the capture link
|
||||
// for verbatim replay on the physical controller; dropped when no link owns the pad.
|
||||
val kind = buf.get().toInt() and 0xFF
|
||||
val len = n - 3
|
||||
if (len > 0) {
|
||||
val data = ByteArray(len)
|
||||
buf.get(data)
|
||||
onHidRaw?.invoke(pad, kind, data)
|
||||
}
|
||||
}
|
||||
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HID-report replay hook for the as-is Steam Controller 2 passthrough: invoked (on the
|
||||
* hidout poll thread) with the wire pad index, the report kind (0 = output report, 1 =
|
||||
* feature report), and the full report bytes (id first) the host's hidraw consumer wrote.
|
||||
* `StreamScreen` wires this to the SC2 capture so Steam's rumble/settings land on the
|
||||
* physical controller.
|
||||
*/
|
||||
@Volatile
|
||||
var onHidRaw: ((pad: Int, kind: Int, data: ByteArray) -> Unit)? = null
|
||||
|
||||
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
||||
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||
0b00000 -> 0
|
||||
|
||||
@@ -91,21 +91,29 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
fun onButton(event: KeyEvent, bit: Int) {
|
||||
val slot = slotFor(event.device) ?: return
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||
disarmExit()
|
||||
}
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
KeyEvent.ACTION_DOWN -> slotButton(slot, bit, down = true, send = event.repeatCount == 0)
|
||||
KeyEvent.ACTION_UP -> slotButton(slot, bit, down = false, send = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
if (down) {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
} else {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||
disarmExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,8 +160,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/**
|
||||
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
|
||||
* holds no live slot (a pad that just unplugged — the update is then dropped). Read from the
|
||||
* feedback poll threads.
|
||||
* holds no live slot (a pad that just unplugged — the update is then dropped) OR the slot is
|
||||
* an [ExternalPad] (its synthetic id resolves to no InputDevice, so rumble binds naturally
|
||||
* fall through to the capture link's own feedback path). Read from the feedback poll threads.
|
||||
*/
|
||||
fun deviceForPad(pad: Int): InputDevice? {
|
||||
for ((deviceId, slot) in slots) {
|
||||
@@ -162,6 +171,50 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
|
||||
* flush + Remove on [close], and full participation in the emergency exit chord.
|
||||
*/
|
||||
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
|
||||
// Live lookup instead of a captured reference: after [close] (or a router release) the
|
||||
// slot is gone from the table and every entry point below degrades to a safe no-op.
|
||||
private val slot get() = slots[syntheticId]
|
||||
|
||||
/** One button transition (a wire [Gamepad].BTN_* bit). On-change only — the caller diffs. */
|
||||
fun button(bit: Int, down: Boolean) {
|
||||
slot?.let { slotButton(it, bit, down, send = true) }
|
||||
}
|
||||
|
||||
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
|
||||
fun axis(id: Int, value: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
}
|
||||
|
||||
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
|
||||
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||
fun close() = closeSlot(syntheticId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
|
||||
* indices are taken. Main thread (like the hot-plug callbacks).
|
||||
*/
|
||||
fun openExternal(pref: Int): ExternalPad? {
|
||||
val index = lowestFreeIndex() ?: return null
|
||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||
val syntheticId = EXTERNAL_ID_BASE - index
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
@@ -252,5 +305,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
|
||||
const val EXIT_HOLD_MS = 1500L
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +301,14 @@ object NativeBridge {
|
||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
|
||||
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
|
||||
* first [len] bytes are the report, id byte first (0x42/0x45/0x47 state, 0x43 battery, …);
|
||||
* len is clamped to 64. Called from the capture thread at the controller's own report rate.
|
||||
*/
|
||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
@@ -312,10 +320,11 @@ object NativeBridge {
|
||||
external fun nativeNextRumble(handle: Long): Long
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
|
||||
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
|
||||
* byte count, or -1 on timeout / session closed.
|
||||
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
|
||||
* capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
|
||||
* Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…, raw as-is
|
||||
* passthrough report=pad 05 kind report-bytes (kind 0 = output report, 1 = feature report).
|
||||
* Returns the byte count, or -1 on timeout / session closed.
|
||||
*/
|
||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* BLE transport for a Steam Controller 2 paired directly with the device (no Puck). The standard
|
||||
* HID service (0x1812) is claimed by the OS (and would feed the pad through the ordinary input
|
||||
* stack in lizard-crippled form), so this talks Valve's vendor GATT service instead — the same
|
||||
* approach Steam itself uses on hosts without a dongle.
|
||||
*
|
||||
* GATT operations are serialized by a small state machine (connect → MTU → discover → subscribe
|
||||
* each notify char → lizard-off → ready); duplicate callbacks (the Android stack sometimes fires
|
||||
* `onMtuChanged` twice) are ignored. Notified state reports arrive with the report-id byte
|
||||
* stripped by the transport, so `0x45` (`ID_STATE_BLE`) is re-prepended for ≥40-byte payloads —
|
||||
* the wire then carries the same id-first framing as USB.
|
||||
*
|
||||
* Requires BLUETOOTH_CONNECT (the caller gates on it); connection priority is bumped to HIGH to
|
||||
* pull the connection interval from ~50 ms down to ~11 ms.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class Sc2BleLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
private enum class State { IDLE, CONNECTING, MTU_REQUESTED, DISCOVERING, SUBSCRIBING, READY }
|
||||
|
||||
private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var writeChar: BluetoothGattCharacteristic? = null
|
||||
private val pendingSubs = mutableListOf<BluetoothGattCharacteristic>()
|
||||
private var subsIndex = 0
|
||||
private val writeBusy = AtomicBoolean(false)
|
||||
private var lizardTicker: Thread? = null
|
||||
|
||||
@Volatile private var state = State.IDLE
|
||||
|
||||
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
|
||||
fun pairedControllers(): List<BluetoothDevice> = runCatching {
|
||||
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
||||
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
||||
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
|
||||
fun start(address: String): Boolean {
|
||||
val adapter = manager.adapter ?: return false
|
||||
if (!adapter.isEnabled) return false
|
||||
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
|
||||
state = State.CONNECTING
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one raw report from the host: output reports (rumble) ride WRITE_NO_RESPONSE so they
|
||||
* can't queue behind acks at the 25 Hz resend rate; feature reports (settings) use an acked
|
||||
* write. The report-id byte stays in the payload (the firmware's vendor-channel framing).
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (state != State.READY || data.isEmpty()) return
|
||||
val g = gatt ?: return
|
||||
val ch = writeChar ?: return
|
||||
runCatching {
|
||||
ch.value = data
|
||||
ch.writeType = if (kind == 0) {
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
} else {
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
}
|
||||
g.writeCharacteristic(ch)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendLizardOff() {
|
||||
if (state != State.READY) return
|
||||
val g = gatt ?: return
|
||||
val ch = writeChar ?: return
|
||||
if (!writeBusy.compareAndSet(false, true)) return // previous acked write still in flight
|
||||
runCatching {
|
||||
ch.value = Sc2Device.DISABLE_LIZARD
|
||||
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
if (!g.writeCharacteristic(ch)) writeBusy.set(false)
|
||||
}.onFailure { writeBusy.set(false) }
|
||||
}
|
||||
|
||||
/** Disconnect and stop the lizard ticker. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
lizardTicker?.interrupt()
|
||||
lizardTicker = null
|
||||
runCatching { gatt?.disconnect() }
|
||||
runCatching { gatt?.close() }
|
||||
gatt = null
|
||||
writeChar = null
|
||||
pendingSubs.clear()
|
||||
subsIndex = 0
|
||||
state = State.IDLE
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
// ~11 ms connection interval instead of the ~50 ms default — input latency.
|
||||
g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
||||
if (state == State.CONNECTING) {
|
||||
state = State.MTU_REQUESTED
|
||||
if (!g.requestMtu(DESIRED_MTU)) {
|
||||
state = State.DISCOVERING
|
||||
g.discoverServices()
|
||||
}
|
||||
}
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
val wasLive = state != State.IDLE
|
||||
runCatching { g.close() }
|
||||
gatt = null
|
||||
writeChar = null
|
||||
pendingSubs.clear()
|
||||
subsIndex = 0
|
||||
state = State.IDLE
|
||||
if (wasLive) onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
if (state != State.MTU_REQUESTED) return // fired twice on some stacks — act once
|
||||
state = State.DISCOVERING
|
||||
g.discoverServices()
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (state != State.DISCOVERING || status != BluetoothGatt.GATT_SUCCESS) return
|
||||
val valve = g.getService(VALVE_SERVICE) ?: run {
|
||||
Log.e(TAG, "Valve vendor service missing — not an SC2?")
|
||||
return
|
||||
}
|
||||
pendingSubs.clear()
|
||||
writeChar = null
|
||||
for (ch in valve.characteristics) {
|
||||
val short = shortUuid(ch.uuid) ?: continue
|
||||
val canNotify = ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
||||
val canWrite = ch.properties and (
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE or
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE
|
||||
) != 0
|
||||
if (canNotify && short in NOTIFY_LOW..NOTIFY_HIGH) pendingSubs.add(ch)
|
||||
if (canWrite && short in WRITE_LOW..WRITE_HIGH && writeChar == null) writeChar = ch
|
||||
}
|
||||
subsIndex = 0
|
||||
state = State.SUBSCRIBING
|
||||
subscribeNext(g)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) {
|
||||
if (state == State.SUBSCRIBING) subscribeNext(g)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||
writeBusy.set(false)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
||||
val data = ch.value ?: return
|
||||
// BLE strips the report-id prefix; restore 0x45 on state-sized payloads so the raw
|
||||
// wire framing matches USB. Short payloads (battery/status) pass through as-is.
|
||||
if (data.size >= 40) {
|
||||
val framed = ByteArray(data.size + 1)
|
||||
framed[0] = Sc2Device.ID_STATE_BLE.toByte()
|
||||
System.arraycopy(data, 0, framed, 1, data.size)
|
||||
onReport(framed, framed.size)
|
||||
} else {
|
||||
onReport(data, data.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeNext(g: BluetoothGatt) {
|
||||
if (subsIndex >= pendingSubs.size) {
|
||||
state = State.READY
|
||||
Log.i(TAG, "SC2 BLE link up (${pendingSubs.size} notify chars)")
|
||||
sendLizardOff()
|
||||
// The firmware watchdog re-enables lizard mode; refresh on SDL's cadence until the
|
||||
// host's Steam takes over via the raw plane (its writes land through writeRaw too).
|
||||
lizardTicker = Thread({
|
||||
while (state == State.READY) {
|
||||
try {
|
||||
Thread.sleep(Sc2Device.LIZARD_REFRESH_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
sendLizardOff()
|
||||
}
|
||||
}, "pf-sc2-lizard").apply { isDaemon = true; start() }
|
||||
return
|
||||
}
|
||||
val ch = pendingSubs[subsIndex++]
|
||||
g.setCharacteristicNotification(ch, true)
|
||||
val cccd = ch.getDescriptor(CCCD) ?: return subscribeNext(g)
|
||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
if (!g.writeDescriptor(cccd)) subscribeNext(g) // lose this one, try the rest
|
||||
}
|
||||
|
||||
/** The 32-bit short id of a Valve vendor UUID, or null for foreign UUIDs. */
|
||||
private fun shortUuid(uuid: UUID): Long? {
|
||||
val s = uuid.toString()
|
||||
if (!s.endsWith(VALVE_UUID_TAIL)) return null
|
||||
return s.substring(0, 8).toLongOrNull(16)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2BleLink"
|
||||
|
||||
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
||||
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
||||
const val NOTIFY_LOW = 0x100f6c75L
|
||||
const val NOTIFY_HIGH = 0x100f6c7aL
|
||||
const val WRITE_LOW = 0x100f6cb5L
|
||||
const val WRITE_HIGH = 0x100f6cbeL
|
||||
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
||||
|
||||
/** Enough for a state payload (45 B) + ATT header with margin. */
|
||||
const val DESIRED_MTU = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* One captured Steam Controller 2 for one stream session — the glue between a transport link
|
||||
* ([Sc2UsbLink] / [Sc2BleLink]) and the wire:
|
||||
*
|
||||
* - **Raw plane (the point):** every input report is forwarded verbatim
|
||||
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
|
||||
* Steam Input drives like the physical controller.
|
||||
* - **Typed mirror:** buttons/sticks/triggers are ALSO diffed onto the ordinary per-transition
|
||||
* plane, so the emergency exit chord works, and a host that degraded the kind (no UHID → the
|
||||
* Xbox 360 pad) still gets a playable controller.
|
||||
* - **Raw return:** the host's hidraw writes (Steam's `0x80` rumble output reports, lizard/IMU
|
||||
* feature settings) arrive via [GamepadFeedback.onHidRaw] → [onHidRaw] → the link, landing on
|
||||
* the real controller's motors/firmware.
|
||||
*
|
||||
* The wire slot is claimed lazily on the FIRST state report — a Puck with no controller powered
|
||||
* on stays invisible to the host — and released (with a wireless-disconnect event or on [stop])
|
||||
* so pad indices never leak. Report callbacks arrive on the link's own thread; the router's slot
|
||||
* table and chord timer are thread-safe for this (same contract as the feedback poll threads).
|
||||
*/
|
||||
class Sc2Capture(
|
||||
context: Context,
|
||||
private val router: GamepadRouter,
|
||||
) {
|
||||
private val usb = Sc2UsbLink(context, ::onReport, ::onLinkClosed)
|
||||
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
|
||||
private var activeLink: Int = LINK_NONE
|
||||
|
||||
private var pad: GamepadRouter.ExternalPad? = null
|
||||
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
|
||||
|
||||
// Typed-mirror diff state (wire units).
|
||||
private val state = Sc2Device.State()
|
||||
private var wireButtons = 0
|
||||
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||
|
||||
/** First attached SC2/Puck USB device, for the permission flow. */
|
||||
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||
|
||||
/**
|
||||
* The first already-bonded BLE Steam Controller's address, or null. The caller checks
|
||||
* BLUETOOTH_CONNECT first (without it the bonded list reads as empty anyway).
|
||||
*/
|
||||
fun pairedBleAddress(): String? = ble.pairedControllers().firstOrNull()?.address
|
||||
|
||||
/** Start capturing [dev] over USB (permission already granted). */
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (activeLink != LINK_NONE) return false
|
||||
val ok = usb.start(dev)
|
||||
if (ok) activeLink = LINK_USB
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Start capturing the bonded BLE controller at [address]. */
|
||||
fun startBle(address: String): Boolean {
|
||||
if (activeLink != LINK_NONE) return false
|
||||
val ok = ble.start(address)
|
||||
if (ok) activeLink = LINK_BLE
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Replay a host raw write on the physical pad — wire to [GamepadFeedback.onHidRaw]. */
|
||||
fun onHidRaw(padIndex: Int, kind: Int, data: ByteArray) {
|
||||
if (padIndex != pad?.index) return // addressed to some other controller
|
||||
when (activeLink) {
|
||||
LINK_USB -> usb.writeRaw(kind, data)
|
||||
LINK_BLE -> ble.writeRaw(kind, data)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
when (activeLink) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
activeLink = LINK_NONE
|
||||
releaseSlot()
|
||||
}
|
||||
|
||||
// ---- link callbacks (link thread) ----
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val id = report[0].toInt() and 0xFF
|
||||
// A Puck relays connect/disconnect for its controller — track the slot accordingly, so
|
||||
// powering the pad off frees its wire index (and the host's virtual device).
|
||||
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
|
||||
if ((report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) releaseSlot()
|
||||
return
|
||||
}
|
||||
if (!Sc2Device.parseState(report, len, state)) {
|
||||
// Battery/status and future report types still belong to the as-is stream.
|
||||
forwardRaw(report, len)
|
||||
return
|
||||
}
|
||||
val p = pad ?: router.openExternal(Gamepad.PREF_STEAMCONTROLLER2)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "SC2 captured → wire pad ${it.index} (as-is passthrough)")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
forwardRaw(report, len)
|
||||
mirrorTyped(p)
|
||||
}
|
||||
|
||||
private fun forwardRaw(report: ByteArray, len: Int) {
|
||||
val p = pad ?: return
|
||||
val n = len.coerceAtMost(rawBuf.capacity())
|
||||
rawBuf.clear()
|
||||
rawBuf.put(report, 0, n)
|
||||
p.hidReport(rawBuf, n)
|
||||
}
|
||||
|
||||
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||
val wired = Sc2Device.wireButtons(state.buttons)
|
||||
var changed = wired xor wireButtons
|
||||
while (changed != 0) {
|
||||
val bit = changed and -changed // lowest changed bit
|
||||
p.button(bit, wired and bit != 0)
|
||||
changed = changed and bit.inv()
|
||||
}
|
||||
wireButtons = wired
|
||||
axis(p, Gamepad.AXIS_LS_X, state.lsX)
|
||||
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
|
||||
axis(p, Gamepad.AXIS_RS_X, state.rsX)
|
||||
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
|
||||
axis(p, Gamepad.AXIS_LT, state.lt)
|
||||
axis(p, Gamepad.AXIS_RT, state.rt)
|
||||
}
|
||||
|
||||
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
|
||||
if (lastAxis[id] == v) return
|
||||
lastAxis[id] = v
|
||||
p.axis(id, v)
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
activeLink = LINK_NONE
|
||||
releaseSlot()
|
||||
}
|
||||
|
||||
private fun releaseSlot() {
|
||||
pad?.close()
|
||||
pad = null
|
||||
wireButtons = 0
|
||||
lastAxis.fill(Int.MIN_VALUE)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2Capture"
|
||||
const val LINK_NONE = 0
|
||||
const val LINK_USB = 1
|
||||
const val LINK_BLE = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Steam Controller 2 (2026, Valve "Ibex" / SDL "Triton") protocol constants + the light state
|
||||
* parser the CLIENT needs. The full report rides the wire verbatim (`nativeSendPadHidReport` →
|
||||
* the host's as-is virtual pad); this parser only extracts what the client itself consumes: the
|
||||
* button word for the typed mirror + exit chord, and sticks/triggers for the degrade path.
|
||||
*
|
||||
* Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
|
||||
* (Valve-maintained), mirrored host-side in `punktfunk-host`'s `triton_proto.rs`.
|
||||
*/
|
||||
object Sc2Device {
|
||||
const val VID_VALVE = 0x28DE
|
||||
|
||||
/** Wired controller. */
|
||||
const val PID_WIRED = 0x1302
|
||||
|
||||
/** Direct BLE identity (transport handled by [Sc2BleLink], not USB). */
|
||||
const val PID_BLE = 0x1303
|
||||
|
||||
/** The wireless Puck dongles (Proteus / Nereid) — controller on USB interfaces 2..5. */
|
||||
const val PID_DONGLE_PROTEUS = 0x1304
|
||||
const val PID_DONGLE_NEREID = 0x1305
|
||||
|
||||
val USB_PIDS = setOf(PID_WIRED, PID_DONGLE_PROTEUS, PID_DONGLE_NEREID)
|
||||
|
||||
/** Dongle interface range that carries controllers (SDL: "interfaces 2..5, currently"). */
|
||||
val DONGLE_IFACES = 2..5
|
||||
|
||||
// Input report ids (`ETritonReportIDTypes`). State layouts share every offset the client
|
||||
// reads (seq/buttons/triggers/sticks); 0x47 only diverges from byte 18 (trackpad timestamp).
|
||||
const val ID_STATE = 0x42
|
||||
const val ID_BATTERY = 0x43
|
||||
const val ID_STATE_BLE = 0x45
|
||||
const val ID_WIRELESS_X = 0x46
|
||||
const val ID_STATE_TIMESTAMP = 0x47
|
||||
const val ID_WIRELESS = 0x79
|
||||
|
||||
/** Wireless status payload byte: controller connected/disconnected through the Puck. */
|
||||
const val WIRELESS_DISCONNECT = 1
|
||||
const val WIRELESS_CONNECT = 2
|
||||
|
||||
// Button bits in the state report's u32 (SDL `TritonButtons`).
|
||||
const val A = 0x00000001
|
||||
const val B = 0x00000002
|
||||
const val X = 0x00000004
|
||||
const val Y = 0x00000008
|
||||
const val QAM = 0x00000010
|
||||
const val R3 = 0x00000020
|
||||
const val VIEW = 0x00000040
|
||||
const val R4 = 0x00000080
|
||||
const val R5 = 0x00000100
|
||||
const val RB = 0x00000200
|
||||
const val DPAD_DOWN = 0x00000400
|
||||
const val DPAD_RIGHT = 0x00000800
|
||||
const val DPAD_LEFT = 0x00001000
|
||||
const val DPAD_UP = 0x00002000
|
||||
const val MENU = 0x00004000
|
||||
const val L3 = 0x00008000
|
||||
const val STEAM = 0x00010000
|
||||
const val L4 = 0x00020000
|
||||
const val L5 = 0x00040000
|
||||
const val LB = 0x00080000
|
||||
const val RPAD_CLICK = 0x00400000
|
||||
|
||||
/**
|
||||
* The feature report that turns lizard mode (built-in keyboard/mouse emulation) off:
|
||||
* `[report id 1][ID_SET_SETTINGS_VALUES 0x87][length 3][SETTING_LIZARD_MODE 9]
|
||||
* [LIZARD_MODE_OFF u16]`, zero-padded to the 64-byte feature size. The firmware watchdog
|
||||
* re-enables lizard mode after a few seconds of silence, so this is re-sent every
|
||||
* [LIZARD_REFRESH_MS] (SDL's cadence) — and the host's Steam sends its own through the raw
|
||||
* plane once it grabs the virtual pad, which lands here too.
|
||||
*/
|
||||
val DISABLE_LIZARD: ByteArray = ByteArray(64).also {
|
||||
it[0] = 0x01 // feature report id
|
||||
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
||||
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
||||
it[3] = 9 // SETTING_LIZARD_MODE
|
||||
// [4..6] = LIZARD_MODE_OFF (0) — already zero
|
||||
}
|
||||
|
||||
const val LIZARD_REFRESH_MS = 3000L
|
||||
|
||||
/** Wire mapping: SC2 button bit → punktfunk `Gamepad.BTN_*`, the inverse of the host's
|
||||
* typed-fallback mapping (`triton_proto::from_gamepad`): paddles R4/L4/R5/L5 =
|
||||
* PADDLE1/2/3/4, QAM = MISC1, right-pad click = the touchpad wire bit. */
|
||||
private val WIRE_MAP = intArrayOf(
|
||||
A, Gamepad.BTN_A,
|
||||
B, Gamepad.BTN_B,
|
||||
X, Gamepad.BTN_X,
|
||||
Y, Gamepad.BTN_Y,
|
||||
LB, Gamepad.BTN_LB,
|
||||
RB, Gamepad.BTN_RB,
|
||||
VIEW, Gamepad.BTN_BACK,
|
||||
MENU, Gamepad.BTN_START,
|
||||
STEAM, Gamepad.BTN_GUIDE,
|
||||
L3, Gamepad.BTN_LS_CLICK,
|
||||
R3, Gamepad.BTN_RS_CLICK,
|
||||
DPAD_UP, Gamepad.BTN_DPAD_UP,
|
||||
DPAD_DOWN, Gamepad.BTN_DPAD_DOWN,
|
||||
DPAD_LEFT, Gamepad.BTN_DPAD_LEFT,
|
||||
DPAD_RIGHT, Gamepad.BTN_DPAD_RIGHT,
|
||||
QAM, Gamepad.BTN_MISC1,
|
||||
R4, Gamepad.BTN_PADDLE1,
|
||||
L4, Gamepad.BTN_PADDLE2,
|
||||
R5, Gamepad.BTN_PADDLE3,
|
||||
L5, Gamepad.BTN_PADDLE4,
|
||||
RPAD_CLICK, Gamepad.BTN_TOUCHPAD,
|
||||
)
|
||||
|
||||
/** Translate an SC2 button word into the wire `Gamepad.BTN_*` bitmask. */
|
||||
fun wireButtons(sc2: Int): Int {
|
||||
var out = 0
|
||||
var i = 0
|
||||
while (i < WIRE_MAP.size) {
|
||||
if (sc2 and WIRE_MAP[i] != 0) out = out or WIRE_MAP[i + 1]
|
||||
i += 2
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The typed-mirror fields of one state report (buttons/sticks/triggers only). */
|
||||
class State {
|
||||
var buttons = 0 // SC2 bit layout
|
||||
var lsX = 0; var lsY = 0 // i16, +y = up (device convention = wire convention)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255 (device 0..32767 scaled down)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the client-consumed fields out of a state report (`0x42`/`0x45`/`0x47` — identical
|
||||
* offsets for everything read here) into [out]. Returns false for non-state / short reports.
|
||||
*/
|
||||
fun parseState(report: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 18) return false
|
||||
when (report[0].toInt() and 0xFF) {
|
||||
ID_STATE, ID_STATE_BLE, ID_STATE_TIMESTAMP -> {}
|
||||
else -> return false
|
||||
}
|
||||
fun i16(o: Int) = ((report[o + 1].toInt() shl 8) or (report[o].toInt() and 0xFF)).toShort().toInt()
|
||||
out.buttons = (report[2].toInt() and 0xFF) or
|
||||
((report[3].toInt() and 0xFF) shl 8) or
|
||||
((report[4].toInt() and 0xFF) shl 16) or
|
||||
((report[5].toInt() and 0xFF) shl 24)
|
||||
out.lt = (i16(6).coerceIn(0, 32767)) shr 7
|
||||
out.rt = (i16(8).coerceIn(0, 32767)) shr 7
|
||||
out.lsX = i16(10); out.lsY = i16(12)
|
||||
out.rsX = i16(14); out.rsY = i16(16)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
|
||||
* dongle (`1304`/`1305`, controller on interfaces 2..5). Claims the Valve vendor interface
|
||||
* (detaching any kernel/OS consumer), runs a read loop on its interrupt-IN endpoint, keeps
|
||||
* lizard mode off on the firmware watchdog cadence, and replays the host's raw writes (Steam's
|
||||
* rumble output reports / settings feature reports) back to the device.
|
||||
*
|
||||
* One controller per link in v1: on a dongle the first claimable controller interface wins
|
||||
* (multi-pad-per-Puck is a follow-up).
|
||||
*/
|
||||
class Sc2UsbLink(
|
||||
context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
private var connection: UsbDeviceConnection? = null
|
||||
private var iface: UsbInterface? = null
|
||||
private var epIn: UsbEndpoint? = null
|
||||
private var epOut: UsbEndpoint? = null
|
||||
|
||||
private var reader: Thread? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim [dev] and start the read + lizard-heartbeat loop. The caller has already obtained
|
||||
* USB permission ([UsbManager.hasPermission]). Returns false when no controller interface
|
||||
* could be claimed.
|
||||
*/
|
||||
fun start(dev: UsbDevice): Boolean {
|
||||
if (!usb.hasPermission(dev)) {
|
||||
Log.e(TAG, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterface(dev, conn) ?: run {
|
||||
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
iface = claimed.first
|
||||
epIn = claimed.second
|
||||
epOut = claimed.third
|
||||
running = true
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 USB link up: PID=0x%04x iface=%d in=0x%02x out=%s".format(
|
||||
dev.productId, claimed.first.id, claimed.second.address,
|
||||
claimed.third?.let { "0x%02x".format(it.address) } ?: "control",
|
||||
),
|
||||
)
|
||||
writeFeature(Sc2Device.DISABLE_LIZARD)
|
||||
reader = Thread({ readLoop(conn, claimed.second) }, "pf-sc2-usb").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the controller interface: vendor-defined (0xFF) class with an interrupt/bulk IN
|
||||
* endpoint, restricted to interfaces 2..5 on a Puck dongle (the SDL-documented controller
|
||||
* range — the other interfaces are the dongle's own control/lizard endpoints).
|
||||
*/
|
||||
private fun claimControllerInterface(
|
||||
dev: UsbDevice,
|
||||
conn: UsbDeviceConnection,
|
||||
): Triple<UsbInterface, UsbEndpoint, UsbEndpoint?>? {
|
||||
val dongle = dev.productId != Sc2Device.PID_WIRED
|
||||
val candidates = (0 until dev.interfaceCount)
|
||||
.map { dev.getInterface(it) }
|
||||
.filter { !dongle || it.id in Sc2Device.DONGLE_IFACES }
|
||||
.sortedByDescending {
|
||||
when (it.interfaceClass) {
|
||||
0xFF -> 2 // vendor-defined first — the Valve gamepad interface
|
||||
UsbConstants.USB_CLASS_HID -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
for (candidate in candidates) {
|
||||
var inEp: UsbEndpoint? = null
|
||||
var outEp: UsbEndpoint? = null
|
||||
for (i in 0 until candidate.endpointCount) {
|
||||
val ep = candidate.getEndpoint(i)
|
||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||
if (!usable) continue
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||
}
|
||||
if (inEp == null) continue
|
||||
// force=true detaches the kernel/OS driver — while claimed, the controller vanishes
|
||||
// from Android's own input stack (no double input alongside our capture).
|
||||
if (conn.claimInterface(candidate, true)) return Triple(candidate, inEp, outEp)
|
||||
Log.w(TAG, "could not claim iface ${candidate.id}, trying next")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun readLoop(conn: UsbDeviceConnection, ep: UsbEndpoint) {
|
||||
val buf = ByteArray(64)
|
||||
var lastLizard = 0L
|
||||
var failures = 0
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
||||
writeFeature(Sc2Device.DISABLE_LIZARD)
|
||||
lastLizard = now
|
||||
}
|
||||
val n = conn.bulkTransfer(ep, buf, buf.size, READ_TIMEOUT_MS)
|
||||
when {
|
||||
n > 0 -> {
|
||||
failures = 0
|
||||
onReport(buf, n)
|
||||
}
|
||||
n == 0 -> {} // empty read — keep going
|
||||
else -> {
|
||||
// -1 covers both timeout (normal, idle controller) and unplug. A real unplug
|
||||
// makes every subsequent transfer fail instantly, so many consecutive fast
|
||||
// failures = the device is gone.
|
||||
if (++failures >= 64) {
|
||||
Log.i(TAG, "SC2 USB read failing persistently — treating as unplug")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
|
||||
* rumble & friends — interrupt-OUT when the interface has one, else a `SET_REPORT(Output)`
|
||||
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
|
||||
* report, id byte first, exactly as hidapi framed it host-side.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
val out = epOut
|
||||
val conn = connection ?: return
|
||||
if (out != null) {
|
||||
conn.bulkTransfer(out, data, data.size, WRITE_TIMEOUT_MS)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> writeFeature(data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeFeature(data: ByteArray) {
|
||||
setReport(REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload).
|
||||
*/
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = iface?.id ?: return
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interface. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
running = false
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
runCatching { iface?.let { connection?.releaseInterface(it) } }
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
iface = null
|
||||
epIn = null
|
||||
epOut = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2UsbLink"
|
||||
const val READ_TIMEOUT_MS = 100
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
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.
|
||||
/// 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).
|
||||
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
|
||||
})
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
//! 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).
|
||||
|
||||
use jni::objects::JObject;
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// `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 (~250–500 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
|
||||
/// glyphs + positional layout on the host side.
|
||||
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
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -270,6 +275,8 @@ public final class PunktfunkConnection {
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||
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 "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
|
||||
self = .switchPro
|
||||
|
||||
@@ -269,6 +269,7 @@ impl PadInfo {
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
GamepadPref::SteamDeck => "Steam Deck",
|
||||
GamepadPref::SteamController => "Steam Controller",
|
||||
GamepadPref::SteamController2 => "Steam Controller 2",
|
||||
GamepadPref::SwitchPro => "Switch Pro",
|
||||
_ => "",
|
||||
}
|
||||
@@ -1606,7 +1607,8 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
HidOutput::Led { pad, .. }
|
||||
| HidOutput::PlayerLeds { pad, .. }
|
||||
| HidOutput::Trigger { pad, .. }
|
||||
| HidOutput::TrackpadHaptic { pad, .. } => *pad,
|
||||
| HidOutput::TrackpadHaptic { pad, .. }
|
||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1924,5 +1926,13 @@ mod slot_tests {
|
||||
}),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
hidout_pad(&HidOutput::HidRaw {
|
||||
pad: 6,
|
||||
kind: 0,
|
||||
data: vec![0x80, 0, 0]
|
||||
}),
|
||||
6
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +584,11 @@ pub struct PunktfunkHidOutput {
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
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;
|
||||
let mut out = PunktfunkHidOutput {
|
||||
kind: 0,
|
||||
@@ -635,8 +639,9 @@ impl PunktfunkHidOutput {
|
||||
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
|
||||
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 +
|
||||
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||
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
|
||||
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + 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_DUALSENSEEDGE == GamepadPref::DualSenseEdge.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.
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||
@@ -2023,10 +2035,15 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
|
||||
.inner
|
||||
.next_hidout(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(h) => {
|
||||
unsafe { *out = PunktfunkHidOutput::from_hid(&h) };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Ok(h) => match PunktfunkHidOutput::from_hid(&h) {
|
||||
Some(v) => {
|
||||
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(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,8 +138,9 @@ impl CompositorPref {
|
||||
/// 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
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`), appended to
|
||||
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
|
||||
/// `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)]
|
||||
pub enum GamepadPref {
|
||||
/// 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) —
|
||||
/// correct Nintendo glyphs + positional layout, gyro/accel, HD rumble back. Needs Linux UHID.
|
||||
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 {
|
||||
/// 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 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
@@ -188,6 +198,7 @@ impl GamepadPref {
|
||||
GamepadPref::SteamDeck => 6,
|
||||
GamepadPref::DualSenseEdge => 7,
|
||||
GamepadPref::SwitchPro => 8,
|
||||
GamepadPref::SteamController2 => 9,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +214,7 @@ impl GamepadPref {
|
||||
6 => GamepadPref::SteamDeck,
|
||||
7 => GamepadPref::DualSenseEdge,
|
||||
8 => GamepadPref::SwitchPro,
|
||||
9 => GamepadPref::SteamController2,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -224,12 +236,16 @@ impl GamepadPref {
|
||||
"switchpro" | "switch-pro" | "switch" | "procontroller" | "pro-controller" => {
|
||||
GamepadPref::SwitchPro
|
||||
}
|
||||
"steamcontroller2" | "steam-controller-2" | "steamcon2" | "sc2" | "ibex" => {
|
||||
GamepadPref::SteamController2
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
@@ -241,6 +257,7 @@ impl GamepadPref {
|
||||
GamepadPref::SteamDeck => "steamdeck",
|
||||
GamepadPref::DualSenseEdge => "dualsenseedge",
|
||||
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_MOTION: u8 = 0x02;
|
||||
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
|
||||
/// 46–54 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):
|
||||
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||
@@ -206,6 +212,19 @@ pub enum RichInput {
|
||||
y: i16,
|
||||
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 {
|
||||
@@ -245,6 +264,11 @@ impl RichInput {
|
||||
out.extend_from_slice(&y.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
|
||||
}
|
||||
@@ -279,6 +303,18 @@ impl RichInput {
|
||||
y: i16::from_le_bytes([b[8], b[9]]),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -288,6 +324,16 @@ const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
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).
|
||||
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
|
||||
@@ -311,6 +357,14 @@ pub enum HidOutput {
|
||||
period: 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 {
|
||||
@@ -339,6 +393,10 @@ impl HidOutput {
|
||||
out.extend_from_slice(&period.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
|
||||
}
|
||||
@@ -370,6 +428,12 @@ impl HidOutput {
|
||||
period: u16::from_le_bytes([b[6], b[7]]),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,11 +342,12 @@ fn gamepad_pref_wire_and_names() {
|
||||
GamepadPref::SteamDeck,
|
||||
GamepadPref::DualSenseEdge,
|
||||
GamepadPref::SwitchPro,
|
||||
GamepadPref::SteamController2,
|
||||
] {
|
||||
assert_eq!(GamepadPref::from_u8(p.to_u8()), 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).
|
||||
for (v, p) in [
|
||||
(0, GamepadPref::Auto),
|
||||
@@ -358,12 +359,13 @@ fn gamepad_pref_wire_and_names() {
|
||||
(6, GamepadPref::SteamDeck),
|
||||
(7, GamepadPref::DualSenseEdge),
|
||||
(8, GamepadPref::SwitchPro),
|
||||
(9, GamepadPref::SteamController2),
|
||||
] {
|
||||
assert_eq!(p.to_u8(), v);
|
||||
assert_eq!(GamepadPref::from_u8(v), p);
|
||||
}
|
||||
// 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.
|
||||
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||
@@ -377,6 +379,14 @@ fn gamepad_pref_wire_and_names() {
|
||||
GamepadPref::from_name("Switch-Pro"),
|
||||
Some(GamepadPref::SwitchPro)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("ibex"),
|
||||
Some(GamepadPref::SteamController2)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("sc2"),
|
||||
Some(GamepadPref::SteamController2)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("xbox-one"),
|
||||
Some(GamepadPref::XboxOne)
|
||||
@@ -1197,6 +1207,33 @@ fn rich_input_roundtrip() {
|
||||
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||
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.
|
||||
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||
@@ -1230,6 +1267,23 @@ fn hid_output_roundtrip() {
|
||||
period: 0x5678,
|
||||
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 {
|
||||
let d = ev.encode();
|
||||
|
||||
@@ -527,6 +527,11 @@ pub mod pad_slots;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_controller.rs"]
|
||||
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
|
||||
/// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -565,6 +570,12 @@ pub mod switch_pro;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/switch_proto.rs"]
|
||||
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
|
||||
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
||||
/// 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;
|
||||
}
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
RichInput::Touchpad { 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() {
|
||||
return;
|
||||
|
||||
@@ -1869,6 +1869,8 @@ struct Pads {
|
||||
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1906,6 +1908,8 @@ impl Pads {
|
||||
switchpro: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1989,6 +1993,11 @@ impl Pads {
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
|
||||
.handle(ev),
|
||||
#[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
|
||||
.xboxone
|
||||
.get_or_insert_with(|| {
|
||||
@@ -2036,7 +2045,8 @@ impl Pads {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { 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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2 => {
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
@@ -2148,6 +2164,9 @@ impl Pads {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -2192,6 +2211,9 @@ impl Pads {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
#[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
|
||||
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -2920,6 +2947,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
| GamepadPref::DualShock4
|
||||
| GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SteamController2
|
||||
| GamepadPref::SwitchPro
|
||||
);
|
||||
if needs_uhid
|
||||
@@ -2985,7 +3013,7 @@ fn physical_steam_controller_present() -> bool {
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
if !matches!(
|
||||
chosen,
|
||||
GamepadPref::SteamDeck | GamepadPref::SteamController
|
||||
GamepadPref::SteamDeck | GamepadPref::SteamController | GamepadPref::SteamController2
|
||||
) {
|
||||
return chosen;
|
||||
}
|
||||
@@ -5622,6 +5650,22 @@ mod tests {
|
||||
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, 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]
|
||||
|
||||
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|
||||
| 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. |
|
||||
|
||||
## Audio / microphone
|
||||
|
||||
@@ -134,6 +134,13 @@
|
||||
// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||
#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
|
||||
// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
|
||||
@@ -368,6 +375,27 @@
|
||||
#define RUMBLE_V2_LEN 10
|
||||
#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
|
||||
// 46–54 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)
|
||||
// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
|
||||
// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
|
||||
|
||||
Reference in New Issue
Block a user