feat(android): a USB Sony pad is captured — rumble, adaptive triggers, lightbar, gyro

A DualSense on a phone had rumble only where the kernel exposed force
feedback, and adaptive triggers / lightbar / player LEDs nowhere — Android
has no platform API for any of them, and Bluetooth offers no raw path
(L2CAP is LE-only; hidraw is root-sealed — Sony's own Remote Play declares
Android triggers unsupported). Claiming the pad's HID interface over USB is
the one unrooted route, so that is what the client now does.

- HidUsbLink: the device-agnostic half of Sc2UsbLink (claim, multiplexed
  UsbRequest loop, newest-wins write queue, signalled-unplug discipline),
  parameterized by device match / interface filter / keep-alive. Sc2UsbLink
  keeps only its SC2 specifics (Puck interfaces 2..5, lizard refresh).
- GamepadFeedback.PadFeedbackSink: 0xCA rumble + 0xCD Led/PlayerLeds/
  Trigger now route to a capture link that owns the pad BEFORE the
  InputDevice vibrator/lights paths — Trigger stops being log-and-drop.
- DsDevice: the byte-exact inverse of the host's dualsense_proto /
  dualshock4_proto — input report 0x01 parse (buttons/sticks/triggers,
  gyro+accel, both touch points; Edge FN/BACK → wire paddles) and output
  builders (DS5 0x02 valid-flag-selective incl. the 11-byte trigger blocks
  and the lightbar-animation release; DS4 0x05 as composed full-state
  writes). Covered by DsDeviceTest (pure JVM).
- DsCapture: stream-mode capture for DualSense / Edge / DS4 — lazy wire
  slot on the first parsed report, typed mirror (exit chord included),
  touch normalized onto the rich plane + per-report motion, feedback
  rendering with a rumble backstop (a USB pad holds its level, so a
  stalled poll thread self-terminates via a scheduled zero-write) and a
  teardown motor-stop over EP0. The claim releases the pad's InputDevice
  slot itself so the wire index hands over deterministically; uncaptured
  (toggle off / permission denied / Bluetooth) the pad stays on the
  ordinary InputDevice path.
- Rich-input shims: nativeSendPadTouch / nativeSendPadMotion →
  RichInput::Touchpad / Motion — the plane the desktop and Apple clients
  already feed; Android pads gain gyro + touchpad on the virtual pad.
- Settings: "DualSense / DualShock passthrough (USB)" (ds_capture, opt-out
  like the SC2 toggle); Controllers screen card with capture status and a
  front-loaded USB grant so streams start without the permission dialog.

The host needs nothing: the DS5/DS4 backends already consume the typed +
rich planes and already emit every feedback event rendered here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:34:57 +02:00
co-authored by Claude Fable 5
parent 696386dee7
commit 1984ddb942
13 changed files with 1653 additions and 364 deletions
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
@@ -149,13 +150,14 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
// capture claims even those away), so it's enumerated on the capture side — USB device
// list + bonded BLE — and re-checked on USB hot-plug.
var sc2Generation by remember { mutableIntStateOf(0) }
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
// row supplements the PadRow below with the capture status + the USB grant.
var usbGeneration by remember { mutableIntStateOf(0) }
DisposableEffect(Unit) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
}
val filter = android.content.IntentFilter().apply {
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(sc2Generation) {
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(usbGeneration) {
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) sc2Probe.pairedBleAddress() else null
}
val sc2Present = sc2Usb != null || sc2Ble != null
val dsUsb = remember(usbGeneration) {
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
}
Group("Gamepads") {
if (sc2Present) Sc2Row(sc2Usb, activity)
dsUsb?.let { DsRow(it) }
if (pads.isEmpty() && !sc2Present) {
Text(
"No controller detected. punktfunk can only forward devices Android " +
@@ -319,6 +328,96 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
}
}
/**
* The Sony USB pad card — capture status + the USB grant, front-loading the permission dialog so
* the capture engages silently at stream start instead of interrupting it. Shown ALONGSIDE the
* pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture itself only runs
* inside a stream, so at menu time this card is pure status.
*/
@Composable
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().dsCapture }
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
val model = DsDevice.modelFor(usbDev.productId)
val label = when (model) {
DsDevice.Model.DUALSENSE -> "DualSense"
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
null -> return
}
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
// this receiver only updates the card).
val action = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
DisposableEffect(usbDev) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) {
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
}
}
androidx.core.content.ContextCompat.registerReceiver(
context,
receiver,
android.content.IntentFilter(action),
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
)
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
Text(
"Wired (USB)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
!settingOn -> Text(
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
"passthrough (USB)\" to capture it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
!permitted -> {
Text(
"Needs USB access — grant it now and streams capture the pad silently.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = {
usbManager.requestPermission(
usbDev,
android.app.PendingIntent.getBroadcast(
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
android.content.Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
android.app.PendingIntent.FLAG_MUTABLE,
),
)
}) {
Text("Grant USB access")
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
@@ -110,6 +110,18 @@ data class Settings(
*/
val sc2Capture: Boolean = true,
/**
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
* by writing USB output reports — rumble works on every phone (no kernel force-feedback
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
* platform API for any of them). ON by default — it engages only when such a pad is attached
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
*/
val dsCapture: Boolean = true,
/**
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -204,6 +216,7 @@ class SettingsStore(context: Context) {
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
@@ -234,6 +247,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -274,6 +288,7 @@ class SettingsStore(context: Context) {
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -816,6 +816,15 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
checked = s.sc2Capture,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
// the CONTROLLER's own motors/LEDs, not this device's.
ToggleRow(
title = "DualSense / DualShock passthrough (USB)",
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
"plus adaptive triggers, lightbar and gyro",
checked = s.dsCapture,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
}
}
}
@@ -54,6 +54,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.kit.DsCapture
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
@@ -367,13 +368,59 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
}
}
}
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
usbDev != null && usbManager.hasPermission(usbDev) -> ds.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.DS_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) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
}
}
dsUsbReceiver = receiver
ContextCompat.registerReceiver(
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
)
usbManager.requestPermission(
usbDev,
PendingIntent.getBroadcast(
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
}
}
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
feedback.onHidRaw = null
feedback.sink = 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)
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
@@ -0,0 +1,301 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.usb.UsbDevice
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
/**
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB — stream mode only.
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
* phone, plus gyro + touchpad the standard path never captured.
*
* Unlike [Sc2Capture] there is no raw passthrough — the host's DualSense/DS4 backends consume
* only typed events — and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
* engage (toggle off, permission denied, Bluetooth).
*
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
* itself if the poll thread stalls — the engine's explicit zeros remain the real stop mechanism.
*/
class DsCapture(
context: Context,
private val router: GamepadRouter,
) : GamepadFeedback.PadFeedbackSink {
private val usb = HidUsbLink(
context,
HidUsbLink.Config(
tag = TAG,
threadName = "pf-ds-usb",
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
// class check already leaves them (and the pad's headset routing) to Android; the
// single HID interface is the only claim.
),
::onReport,
::onLinkClosed,
)
@Volatile private var model: DsDevice.Model? = null
@Volatile private var pad: GamepadRouter.ExternalPad? = null
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
private val state = DsDevice.State()
private var wireButtons = 0
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
private val lastTouchActive = BooleanArray(2)
private val lastTouchX = IntArray(2) { -1 }
private val lastTouchY = IntArray(2) { -1 }
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
// rumble, before any host Led lands) doesn't black the bar out.
@Volatile private var ds4Low = 0
@Volatile private var ds4High = 0
@Volatile private var ds4Rgb = 0x000040
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var backstop: Runnable? = null
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
fun findUsbDevice(): UsbDevice? = usb.findDevice()
/**
* Start capturing [dev] (permission already granted). Claims the HID interface — the kernel
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
* than waiting for the system's removal callback — so the freed wire index is deterministic
* for this capture's ExternalPad instead of racing the first report against the callback. A
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
* reopens a slot on its next input event, so over-matching self-heals.
*/
fun startUsb(dev: UsbDevice): Boolean {
if (model != null) return false
val m = DsDevice.modelFor(dev.productId) ?: return false
if (!usb.start(dev)) return false
model = m
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
}
// Release the firmware's lightbar animation once so host lightbar writes take effect
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
// mid-rumble teardown would leave the motors running with nobody to stop them.
// EP0-direct (the reader thread is stopping; the queue would never drain).
usb.writeControl(stopReport(m))
}
disarmBackstop()
usb.stop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
// ---- link callbacks (link thread) ----
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
disarmBackstop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
var changed = state.buttons xor wireButtons
while (changed != 0) {
val bit = changed and -changed // lowest changed bit
p.button(bit, state.buttons and bit != 0)
changed = changed and bit.inv()
}
wireButtons = state.buttons
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)
}
/**
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
*/
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
for (f in 0 until 2) {
if (state.touchActive[f]) {
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
p.touch(f, true, x, y)
lastTouchActive[f] = true
lastTouchX[f] = x
lastTouchY[f] = y
}
} else if (lastTouchActive[f]) {
p.touch(f, false, lastTouchX[f], lastTouchY[f])
lastTouchActive[f] = false
}
}
p.motion(state.gyro, state.accel)
}
private fun releaseSlot() {
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
val p = pad
if (p != null) {
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
}
p?.close()
pad = null
wireButtons = 0
lastAxis.fill(Int.MIN_VALUE)
lastTouchActive.fill(false)
lastTouchX.fill(-1)
lastTouchY.fill(-1)
}
// ---- PadFeedbackSink (feedback poll threads) ----
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
val m = model ?: return
if (low == 0 && high == 0) {
disarmBackstop()
} else {
armBackstop(backstopMs)
}
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = low
ds4High = high
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
}
}
override fun led(pad: Int, r: Int, g: Int, b: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Rgb = (r shl 16) or (g shl 8) or b
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
}
}
override fun playerLeds(pad: Int, bits: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
}
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
}
private fun writeDs4() = usb.writeRaw(
0,
DsDevice.ds4Report(
ds4Low,
ds4High,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
),
)
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
* composed motor state and carries the current lightbar rather than blacking it out. */
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = 0
ds4High = 0
DsDevice.ds4Report(
0,
0,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
)
} else {
DsDevice.ds5RumbleReport(m, 0, 0)
}
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
private fun armBackstop(ms: Long) {
backstop?.let { mainHandler.removeCallbacks(it) }
val r = Runnable {
backstop = null
model?.let { usb.writeRaw(0, stopReport(it)) }
}
backstop = r
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
}
private fun disarmBackstop() {
backstop?.let { mainHandler.removeCallbacks(it) }
backstop = null
}
private companion object {
const val TAG = "DsCapture"
}
}
@@ -0,0 +1,340 @@
package io.unom.punktfunk.kit
/**
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
* as-is passthrough, nothing rides the wire raw here — the host's DualSense/DS4 backends consume
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
* USB output reports itself.
*
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
* `dualsense_proto.rs` / `dualshock4_proto.rs` — this file is the byte-exact inverse of those
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
* reaches this code — an uncaptured pad stays on the ordinary InputDevice path.
*/
object DsDevice {
const val VID_SONY = 0x054C
const val PID_DUALSENSE = 0x0CE6
const val PID_DUALSENSE_EDGE = 0x0DF2
const val PID_DUALSHOCK4_V1 = 0x05C4
const val PID_DUALSHOCK4_V2 = 0x09CC
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
/**
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
* the physical one), its output-report size (the descriptor-declared size the firmware
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
* onto the wire's 0..65535 space.
*/
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
}
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
fun modelFor(pid: Int): Model? = when (pid) {
PID_DUALSENSE -> Model.DUALSENSE
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
else -> null
}
/**
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
*/
class State {
var buttons = 0
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
var rsX = 0; var rsY = 0
var lt = 0; var rt = 0 // 0..255
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
val accel = IntArray(3)
val touchActive = BooleanArray(2)
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
val touchY = IntArray(2)
}
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
private const val DS5_INPUT_ID = 0x01
// report[8] high nibble (`dualsense_proto::btn0`).
private const val DS5_SQUARE = 0x10
private const val DS5_CROSS = 0x20
private const val DS5_CIRCLE = 0x40
private const val DS5_TRIANGLE = 0x80
// report[9] (`btn1`).
private const val DS5_L1 = 0x01
private const val DS5_R1 = 0x02
private const val DS5_CREATE = 0x10
private const val DS5_OPTIONS = 0x20
private const val DS5_L3 = 0x40
private const val DS5_R3 = 0x80
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
private const val DS5_PS = 0x01
private const val DS5_TOUCHPAD = 0x02
private const val DS5_MUTE = 0x04
private const val EDGE_FN_LEFT = 0x10
private const val EDGE_FN_RIGHT = 0x20
private const val EDGE_BACK_LEFT = 0x40
private const val EDGE_BACK_RIGHT = 0x80
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
// [35..43) two touch points (same 4-byte packing as the DS5).
private const val DS4_L1 = 0x01
private const val DS4_R1 = 0x02
private const val DS4_SHARE = 0x10
private const val DS4_OPTIONS = 0x20
private const val DS4_L3 = 0x40
private const val DS4_R3 = 0x80
private const val DS4_PS = 0x01
private const val DS4_TOUCHPAD = 0x02
/**
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
*/
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
if (model == Model.DUALSHOCK4) {
parseDs4(report, len, out)
} else {
parseDs5(model, report, len, out)
}
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
out.lt = u8(r, 5)
out.rt = u8(r, 6)
val b8 = u8(r, 8)
val b9 = u8(r, 9)
val b10 = u8(r, 10)
var w = hatBits(b8 and 0x0F)
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
// L2/R2 digital bits ride the analog axes instead (wire convention).
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
if (model == Model.DUALSENSE_EDGE) {
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
}
out.buttons = w
if (len >= 28) {
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
}
if (len >= 41) {
unpackTouch(r, 33, out, 0)
unpackTouch(r, 37, out, 1)
}
return true
}
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
val b5 = u8(r, 5)
val b6 = u8(r, 6)
val b7 = u8(r, 7)
out.lt = u8(r, 8)
out.rt = u8(r, 9)
var w = hatBits(b5 and 0x0F)
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
out.buttons = w
if (len >= 25) {
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
}
if (len >= 43) {
unpackTouch(r, 35, out, 0)
unpackTouch(r, 39, out, 1)
}
return true
}
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
private fun hatBits(h: Int): Int = when (h) {
0 -> Gamepad.BTN_DPAD_UP
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
2 -> Gamepad.BTN_DPAD_RIGHT
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
4 -> Gamepad.BTN_DPAD_DOWN
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
6 -> Gamepad.BTN_DPAD_LEFT
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
else -> 0
}
/**
* One 4-byte touch point (shared DS5/DS4 packing — `dualsense_proto::pack_touch`): byte0
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
*/
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
val b0 = u8(r, o)
out.touchActive[slot] = b0 and 0x80 == 0
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
}
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
private fun i16(r: ByteArray, o: Int): Int =
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
private fun stickX(raw: Int): Int = raw * 257 - 32768
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
// ---- Output reports ----
//
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
private const val DS5_FLAG0_R2_EFFECT = 0x04
private const val DS5_FLAG0_L2_EFFECT = 0x08
private const val DS5_FLAG1_LIGHTBAR = 0x04
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
private const val DS5_FLAG2_VIBRATION2 = 0x04
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
const val TRIGGER_EFFECT_LEN = 11
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
/**
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect — the same
* init both hid-playstation and SDL send on open. No-op fields otherwise.
*/
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
}
/**
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
* light/right — the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
*/
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
}
/**
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
* trigger block from the wire (`HidOutput::Trigger` — the game's bytes verbatim), copied to
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
*/
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
val at = if (which == 1) 11 else 22
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
System.arraycopy(effect, 0, it, at, n)
}
/** DS5/Edge lightbar RGB. */
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
it[45] = r.toByte()
it[46] = g.toByte()
it[47] = b.toByte()
}
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
it[44] = (bits and 0x1F).toByte()
}
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
// [6..9) RGB, [9]/[10] blink on/off.
private const val DS4_FLAG0_MOTORS = 0x01
private const val DS4_FLAG0_LED = 0x02
/**
* One full-state DS4 write: motors + lightbar together, both flags set — the composed-state
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
*/
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -46,6 +46,42 @@ class GamepadFeedback(
private val router: GamepadRouter?,
private val deviceVibrator: Vibrator? = null,
) {
/**
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
* resolves null and the platform paths no-op) — the link renders instead, by composing USB
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
* Invoked on the feedback poll threads; implementations must be thread-safe.
*/
interface PadFeedbackSink {
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
* invoked while true. Racing a pad close is fine — a late render is a harmless no-op. */
fun ownsPad(pad: Int): Boolean
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
* [backstopMs] as the self-termination net — see [GamepadFeedback.renderRumble]). */
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
/** Lightbar RGB. */
fun led(pad: Int, r: Int, g: Int, b: Int)
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
fun playerLeds(pad: Int, bits: Int)
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
* block (mode byte + parameters) exactly as the game wrote it host-side. */
fun trigger(pad: Int, which: Int, effect: ByteArray)
}
/**
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
* [onHidRaw]; cleared before the poll threads stop.
*/
@Volatile
var sink: PadFeedbackSink? = null
private companion object {
const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01
@@ -221,6 +257,12 @@ class GamepadFeedback(
// controller 1 unconditionally rather than only motor-less pads — capability probing
// already decided the bind, and the user opted in.
if (pad == 0) renderDeviceRumble(low, high, durationMs)
// A captured pad's link renders on the physical controller itself (its slot has no
// InputDevice, so the vibrator bind below would resolve null and drop the command).
sink?.takeIf { it.ownsPad(pad) }?.let {
it.rumble(pad, low, high, durationMs)
return
}
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
@@ -313,23 +355,36 @@ class GamepadFeedback(
val g = buf.get().toInt() and 0xFF
val b = buf.get().toInt() and 0xFF
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.led(pad, r, g, b)
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
}
TAG_PLAYER_LEDS -> {
val bits = buf.get().toInt() and 0x1F
val player = playerIndexForBits(bits)
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.playerLeds(pad, bits)
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
}
TAG_TRIGGER -> {
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
val effLen = n - 3 // [pad][kind][which] header, then the effect block
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
)
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null && effLen > 0) {
// A captured DualSense: the raw trigger block replays onto the physical pad.
val effect = ByteArray(effLen)
buf.get(effect)
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
s.trigger(pad, which, effect)
} else {
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No platform adaptive-trigger API — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
)
}
}
TAG_HID_RAW -> {
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
@@ -210,6 +210,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
accel[0], accel[1], accel[2],
)
}
}
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
fun close() = closeSlot(syntheticId)
}
@@ -228,6 +246,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
return ExternalPad(syntheticId, index)
}
/**
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
* detaches the kernel driver, so the system's own removal callback would close it moments
* later anyway doing it at claim time makes the freed wire index deterministic for the
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
* slot on its next input event). Main thread, like the hot-plug callbacks.
*/
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
/**
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]).
@@ -0,0 +1,399 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
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.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/**
* Generic USB transport for a client-captured HID controller the device-agnostic half of what
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
* interface(s) `force = true` detaches the kernel/OS driver, so a captured pad can't
* double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest] read loop, and
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
* else EP0 `SET_REPORT`).
*
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence
* the SC2's lizard-mode refresh; a DualSense needs none).
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (an SC2 on-glass round tripped exactly this a 5 s silence heuristic firing on an idle pad).
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
*/
class HidUsbLink(
private val context: Context,
private val config: Config,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
) {
/**
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
* HID/vendor-class interfaces get claimed (the class check itself is built in) e.g. the SC2
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
*/
class Config(
val tag: String,
val threadName: String,
val deviceMatch: (UsbDevice) -> Boolean,
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
val keepAliveFeatures: List<ByteArray> = emptyList(),
val keepAliveMs: Long = 0,
)
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where output/feature writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports, submitted by the reader thread only one thread may drive a
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
* request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
running = true
Log.i(
config.tag,
"USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(config.tag, "USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
if (config.keepAliveFeatures.isNotEmpty()) {
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
}
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional the fallback is
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
* from Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (!config.ifaceFilter(dev, iface)) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
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
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(config.tag, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(config.tag, "no IN request could be queued")
finishReader(claims)
return
}
val scratch = ByteArray(64)
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
now - lastKeepAlive >= config.keepAliveMs
) {
// Refresh the firmware settings on the streaming interface (else every live
// one, before a streaming interface is known) — replaying also repairs state
// some other consumer changed after capture started.
val target = activeClaim
if (target != null) sendKeepAlive(conn, target.iface.id)
else live.forEach { sendKeepAlive(conn, it.iface.id) }
lastKeepAlive = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
config.tag,
"first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
/**
* Write one raw report to the device: kind 0 = output report (the active interface's
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the sender re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
}
}
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
}
/**
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
* queue for a teardown write that must land while the reader thread is stopping and the
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
* thread: EP0 control transfers are independent of the reader's `requestWait`.
*/
fun writeControl(data: ByteArray) {
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
}
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
}
/**
* 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). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
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,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
}
@@ -407,6 +407,30 @@ object NativeBridge {
*/
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
/**
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
* are normalized 0..65535 in SCREEN convention (+y down the wire's fixed meaning); active
* false lifts the finger. Send on change only the host holds per-slot state.
*/
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
/**
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units the host passes
* them straight into the virtual DualSense report. Called at the pad's report rate.
*/
external fun nativeSendPadMotion(
handle: Long,
pad: Int,
gyroPitch: Int,
gyroYaw: Int,
gyroRoll: Int,
accelX: Int,
accelY: Int,
accelZ: Int,
)
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/**
@@ -1,28 +1,13 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
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.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/**
* USB transport for a Steam Controller 2 wired (`28DE:1302`) or through the wireless Puck
* dongle (`1304`/`1305`). Claims the controller interface(s) detaching the OS input stack, so
* the pad can't double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest]
* read loop, 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.
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
* SC2-specific:
*
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
* HID interface each, and there is no way to know which slot a controller bonded to claiming
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
* on-glass symptom: the pad surfaced as a generic InputDevice Xbox360). Whichever interface
* streams state becomes the write target for rumble/settings.
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence the generic link's keep-alive.
*/
class Sc2UsbLink(
private val context: Context,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
context: Context,
onReport: (report: ByteArray, len: Int) -> Unit,
onClosed: () -> Unit,
) {
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where rumble/settings writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread only
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
* returns ANY completed request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
private val link = HidUsbLink(
context,
HidUsbLink.Config(
tag = "Sc2UsbLink",
threadName = "pf-sc2-usb",
deviceMatch = {
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
},
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
ifaceFilter = { dev, iface ->
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
},
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
),
onReport,
onClosed,
)
/** 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
}
fun findDevice(): UsbDevice? = link.findDevice()
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing 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 = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
running = true
Log.i(
TAG,
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
claimed.forEach { configureInputMode(conn, it.iface.id) }
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
* of a Puck's controller slots (interfaces 2..5 the controller may be bonded to any of
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
* Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val dongle = dev.productId != Sc2Device.PID_WIRED
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
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
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(TAG, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(TAG, "no IN request could be queued")
finishReader(claims)
return
}
val scratch = ByteArray(64)
var lastLizard = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
// Refresh both required firmware modes. The raw-joystick setting is normally
// persistent, but replaying it also repairs a host/driver that enabled ADC
// coordinates after capture started.
val target = activeClaim
if (target != null) configureInputMode(conn, target.iface.id)
else live.forEach { configureInputMode(conn, it.iface.id) }
lastLizard = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
TAG,
"SC2 first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
fun start(dev: UsbDevice): Boolean = link.start(dev)
/**
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
* rumble & friends the active interface's interrupt-OUT, 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.
* rumble & friends), kind 1 = feature report. [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 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the host re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
}
}
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, data)
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
}
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
}
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
sendReport(conn, ifaceId, 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). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
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,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val TAG = "Sc2UsbLink"
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
fun stop() = link.stop()
}
@@ -0,0 +1,224 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM tests of the Sony USB report codec ([DsDevice]) the byte-exact inverse of the
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
* Run: `./gradlew :kit:testDebugUnitTest`.
*/
class DsDeviceTest {
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
ByteArray(64).also {
it[0] = 0x01
// Sticks centred, hat neutral (8).
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[8] = 0x08
// Touch points inactive (bit7 set).
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
mutate(it)
}
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
ByteArray(64).also {
it[0] = 0x01
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[5] = 0x08
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
mutate(it)
}
// ---- input parse ----
@Test
fun ds5ButtonsMapPositionally() {
val s = DsDevice.State()
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
val r = ds5Report {
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
assertEquals(expected, s.buttons)
}
@Test
fun ds5SticksInvertYAndCoverTheFullRange() {
val s = DsDevice.State()
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
val r = ds5Report {
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
it[5] = 0x40; it[6] = 0xFF.toByte()
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
assertEquals(-32768, s.lsX)
assertEquals(32767, s.lsY) // device up → wire +32767
assertEquals(32767, s.rsX)
assertEquals(-32768, s.rsY) // device down → wire 32768
assertEquals(0x40, s.lt)
assertEquals(0xFF, s.rt)
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
val c = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
assertEquals(128, c.lsX)
assertEquals(-129, c.lsY)
}
@Test
fun ds5MotionAndTouchUnpack() {
val s = DsDevice.State()
val r = ds5Report {
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
it[16] = 0x02; it[17] = 0x01
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
it[33] = 0x05
it[34] = 0x7F
it[35] = (0x07 or (0x07 shl 4)).toByte()
it[36] = 0x43
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
assertEquals(0x0102, s.gyro[0])
assertEquals(-2, s.accel[2])
assertTrue(s.touchActive[0])
assertEquals(1919, s.touchX[0])
assertEquals(1079, s.touchY[0])
assertFalse(s.touchActive[1])
}
@Test
fun edgePaddlesParseOnlyOnTheEdge() {
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
val edge = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
assertEquals(
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
edge.buttons,
)
val plain = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
}
@Test
fun ds4LayoutDiffersWhereItShould() {
val s = DsDevice.State()
val r = ds4Report {
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
it[6] = (0x10 or 0x20).toByte() // share | options
it[7] = 0x03 // PS | touchpad click
it[8] = 0x11 // L2 analog
it[9] = 0x99.toByte() // R2 analog
// gyro yaw at 15.. (second i16 of 13..19).
it[15] = 0x34; it[16] = 0x12
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
it[35] = 0x03
it[36] = 0x64
it[37] = 0xD0.toByte()
it[38] = 0x3A
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
assertEquals(
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
s.buttons,
)
assertEquals(0x11, s.lt)
assertEquals(0x99, s.rt)
assertEquals(0x1234, s.gyro[1])
assertTrue(s.touchActive[0])
assertEquals(100, s.touchX[0])
assertEquals(941, s.touchY[0])
}
@Test
fun rejectsForeignAndShortReports() {
val s = DsDevice.State()
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
}
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
@Test
fun ds5RumbleReportFlagsAndMotors() {
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
assertEquals(48, r.size)
assertEquals(0x02, r[0].toInt())
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
// A nonzero amplitude never collapses to motor 0.
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
// The Edge's output report is the 64-byte variant.
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
}
@Test
fun ds5TriggerReportPlacesTheBlockPerSide() {
val effect = ByteArray(11) { (it + 1).toByte() }
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
assertEquals(1, r2[11].toInt()) // block at [11..22)
assertEquals(11, r2[21].toInt())
assertEquals(0, r2[22].toInt())
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
assertEquals(1, l2[22].toInt()) // block at [22..33)
assertEquals(11, l2[32].toInt())
// Oversized wire effects clamp to the 11-byte hardware block.
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
assertEquals(0, big[22].toInt())
}
@Test
fun ds5LightbarPlayerLedsAndInit() {
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
}
@Test
fun ds4ReportIsAFullStateWrite() {
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
assertEquals(32, r.size)
assertEquals(0x05, r[0].toInt())
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
assertEquals(0, r[9].toInt()) // blink untouched
}
@Test
fun modelResolution() {
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
assertEquals(null, DsDevice.modelFor(0x1234))
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
}
}
@@ -406,3 +406,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
data,
});
}
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
/// the capture diffs, the host holds per-slot state.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
finger: jint,
active: jboolean,
x: jint,
y: jint,
) {
if handle == 0 {
return;
}
// 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::Touchpad {
pad: (pad as u32 & 0xF) as u8,
finger: (finger as u32 & 0x1) as u8,
active: active != 0,
x: (x as i64).clamp(0, 65535) as u16,
y: (y as i64).clamp(0, 65535) as u16,
});
}
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
/// controller's report rate.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
gyro_pitch: jint,
gyro_yaw: jint,
gyro_roll: jint,
accel_x: jint,
accel_y: jint,
accel_z: jint,
) {
if handle == 0 {
return;
}
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
// 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::Motion {
pad: (pad as u32 & 0xF) as u8,
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
accel: [c(accel_x), c(accel_y), c(accel_z)],
});
}