feat(android): multi-controller support

Roll the pf-client-core slot pattern to the Android client (Kotlin + JNI):

- New kit/GamepadRouter.kt: the Android analogue of the client-core Slot
  model — a deviceId→Slot map assigning each InputDevice a stable lowest-free
  wire pad index held for its lifetime, GamepadArrival(pref) before a pad's
  first input, GamepadRemove on onInputDeviceRemoved, per-slot AxisMapper +
  held-bitmask so two pads never clobber each other. The isForwardable gate
  (excludes DualSense/DS4 all-zero sensor sibling nodes) is centralized in
  slotFor so no entry point can open a phantom slot.
- native/src/session/input.rs: JNI shims take a pad arg -> flags=pad
  (nativeSendGamepadButton/Axis, plus nativeSendGamepadArrival/Remove).
- native/src/feedback.rs: pad carried in rumble bits 49..52 + a leading
  hidout pad byte; GamepadFeedback.kt routes rumble/lightbar/LED back to the
  originating device by pad via deviceForPad.
- MainActivity.kt routes key/motion events by device; ControllersScreen.kt
  badges every forwarded pad (was hardcoded i==0), reading getControllerNumber.

A lone controller lands on wire index 0, so its per-transition datagrams stay
byte-identical to the old single-pad path. gradle :app:assembleDebug green
(Rust cross-compiled via cargo-ndk); JNI signatures hand-verified 1:1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:52:26 +02:00
parent 76be4c3e12
commit 0ad4e6eff7
9 changed files with 527 additions and 237 deletions
@@ -158,8 +158,11 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
pads.forEachIndexed { i, dev -> // Every real controller is forwarded now (Automatic forwards them all, each on its own
PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting) // wire pad index) — not just the first. A joystick-only device Android doesn't classify as
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
pads.forEach { dev ->
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
} }
} }
@@ -222,8 +225,12 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (forwarded) { if (forwarded) {
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
// user can tell which physical pad is which. The stream's wire pad index is
// assigned separately (lowest-free per device) once streaming starts.
val number = dev.controllerNumber
Text( Text(
"forwarded to host", if (number > 0) "forwarded · player $number" else "forwarded to host",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -319,6 +326,15 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
} }
} }
/**
* Whether this device is actually forwarded to the host — the same rule the stream's [GamepadRouter]
* applies: a real, non-virtual controller whose source classes include GAMEPAD. A joystick-only node
* (e.g. a DualSense motion-sensor sibling, or an adapter that enumerates as bare joystick) shows in
* the list but isn't forwarded.
*/
private fun isForwarded(dev: InputDevice): Boolean =
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */ /** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
private fun deviceHasVibrator(dev: InputDevice): Boolean = private fun deviceHasVibrator(dev: InputDevice): Boolean =
if (Build.VERSION.SDK_INT >= 31) { if (Build.VERSION.SDK_INT >= 31) {
@@ -16,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
@@ -27,8 +28,12 @@ class MainActivity : ComponentActivity() {
*/ */
var streamHandle: Long = 0L var streamHandle: Long = 0L
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */ /**
var axisMapper: Gamepad.AxisMapper? = null * Multi-controller router for the active session (built/released by StreamScreen): assigns each
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
* hot-plug, and routes rumble/HID feedback back by pad index. Null while not streaming.
*/
var gamepadRouter: GamepadRouter? = null
/** /**
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]). * Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
@@ -44,9 +49,6 @@ class MainActivity : ComponentActivity() {
*/ */
var requestStreamExit: (() -> Unit)? = null var requestStreamExit: (() -> Unit)? = null
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
private var heldPadButtons = 0
/** /**
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad * Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad * remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
@@ -125,22 +127,11 @@ class MainActivity : ComponentActivity() {
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.buttonBit(event.keyCode) val bit = Gamepad.buttonBit(event.keyCode)
if (bit != 0) { if (bit != 0) {
when (event.action) { // The router forwards the bit on this device's own wire pad index, tracks held
// repeatCount guard: don't re-send a held button as auto-repeat. // state per pad, and reports when the emergency-exit chord (Select + Start + L1 +
KeyEvent.ACTION_DOWN -> { // R1) completed on any one pad (a couch user has no keyboard/Back).
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true) if (gamepadRouter?.onButton(event, bit) == true) {
heldPadButtons = heldPadButtons or bit requestStreamExit?.let { exit -> window.decorView.post { exit() } }
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
// (a couch user has no keyboard/Back). Fired once per full chord.
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
heldPadButtons = 0
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
}
}
KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false)
heldPadButtons = heldPadButtons and bit.inv()
}
} }
return true // consumed return true // consumed
} }
@@ -203,7 +194,7 @@ class MainActivity : ComponentActivity() {
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (streamHandle != 0L) { if (streamHandle != 0L) {
if (axisMapper?.onMotion(event) == true) return true if (gamepadRouter?.onMotion(event) == true) return true
return super.dispatchGenericMotionEvent(event) return super.dispatchGenericMotionEvent(event)
} }
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below. // The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
@@ -248,9 +239,4 @@ class MainActivity : ComponentActivity() {
-> true -> true
else -> KeyEvent.isGamepadButton(kc) else -> KeyEvent.isGamepadButton(kc)
} }
private companion object {
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
}
} }
@@ -32,8 +32,8 @@ import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -174,18 +174,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val priorOrientation = activity?.requestedOrientation val priorOrientation = activity?.requestedOrientation
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity?.streamHandle = handle // route hardware keys to this session activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes // Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(context, handle, initialSettings.gamepad)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. // the keep-alive linger), unlike a host-ended / backgrounded drop.
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close. // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
val feedback = GamepadFeedback(handle).also { it.start() } // index via the router; poll threads stopped + joined before the router is released and the
// session closed.
val feedback = GamepadFeedback(handle, router).also { it.start() }
onDispose { onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
activity?.axisMapper?.reset() // release-all so nothing sticks on the host router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.axisMapper = null activity?.gamepadRouter = null
activity?.streamHandle = 0L activity?.streamHandle = 0L
activity?.requestStreamExit = null activity?.requestStreamExit = null
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
@@ -171,47 +171,26 @@ object Gamepad {
} }
/** /**
* Maps joystick MotionEvents to axis (+ HAT→dpad) sends for one session, **on change only**. * Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per * **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing * instance per forwarded controller (owned by [GamepadRouter], which routes each device's events
* sticks on the host (which has no client-side held-state knowledge). * to its own mapper so a second pad can't clobber the first); call [reset] on that slot closing
* (disconnect / session stop) so nothing sticks on the host (which has no client-side held-state
* knowledge).
* *
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device * The router only ever feeds this a qualifying event from the mapper's own device — a real
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the * gamepad (its source classes include GAMEPAD), never a controller's joystick-classified sibling
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion * node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
* sensors) and any second pad report every axis as 0, and folding them into the same state * folds the event straight in without re-qualifying it.
* flapped a held trigger/stick between its value and 0 on every event interleave.
*/ */
class AxisMapper(private val handle: Long) { class AxisMapper(private val handle: Long, private val pad: Int) {
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity). // Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
private val last = IntArray(6) { Int.MIN_VALUE } private val last = IntArray(6) { Int.MIN_VALUE }
private var hatX = 0 // -1 / 0 / +1 private var hatX = 0 // -1 / 0 / +1
private var hatY = 0 private var hatY = 0
/** deviceId of the controller pad 0 is pinned to; 1 until the first qualifying event. */ /** Fold one joystick ACTION_MOVE from this mapper's controller onto its pad index. */
private var deviceId = -1 fun onMotion(event: MotionEvent) {
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
fun onMotion(event: MotionEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
// joystick-class remotes don't — and those report every pad axis as 0 (see the
// class doc for the held-trigger flap this caused).
val dev = event.device ?: return false
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
// Single-pad model: pin to the first qualifying controller so a second pad (or its
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
if (deviceId != event.deviceId) {
if (deviceId != -1) {
if (InputDevice.getDevice(deviceId) != null) return false
reset() // the pinned pad is gone — lift its held state before adopting
}
deviceId = event.deviceId
}
// Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up. // Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up.
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X))) sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y))) sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
@@ -253,10 +232,9 @@ object Gamepad {
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true) if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
hatY = hy hatY = hy
} }
return true
} }
/** Release-all: zero every axis and clear the held dpad. */ /** Release-all: zero every axis and clear the held dpad (all on this mapper's pad index). */
fun reset() { fun reset() {
for (id in 0..5) sendAxis(id, 0) for (id in 0..5) sendAxis(id, 0)
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false) if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
@@ -268,10 +246,10 @@ object Gamepad {
private fun sendAxis(id: Int, v: Int) { private fun sendAxis(id: Int, v: Int) {
if (last[id] == v) return if (last[id] == v) return
last[id] = v last[id] = v
NativeBridge.nativeSendGamepadAxis(handle, id, v) NativeBridge.nativeSendGamepadAxis(handle, id, v, pad)
} }
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down) private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)
// 1..1 float → ±32767 i16 (matches the Apple client's 32767 scale). // 1..1 float → ±32767 i16 (matches the Apple client's 32767 scale).
private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt() private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt()
@@ -15,21 +15,26 @@ import android.view.InputDevice
import java.nio.ByteBuffer import java.nio.ByteBuffer
/** /**
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll * Host→client gamepad feedback for one session, routed per controller by wire pad index. Two daemon
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's * poll threads drain the blocking native pulls and render in Kotlin: rumble → the addressed
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 2830; HID-output → lightbar / * controller's `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 2830; HID-output
* player-LED via `LightsManager` (API 33+); adaptive * → that controller's lightbar / player-LED via `LightsManager` (API 33+); adaptive triggers are
* triggers are parse-validated and logged (Android has no public adaptive-trigger API). * parse-validated and logged (Android has no public adaptive-trigger API).
*
* Each pull carries the wire pad index it is addressed to; [GamepadRouter.deviceForPad] resolves it
* to the physical controller currently holding that index — so a rumble the host aimed at pad 1
* drives pad 1's motors, and an update for an index with no live controller (a pad that just
* unplugged) is dropped. Per-controller rumble/light bindings are built lazily and cached by device
* id (bounded — at most 16 pads).
* *
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a * Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and * flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
* this MUST run before `nativeClose` frees the session handle. * this MUST run before the router is released and `nativeClose` frees the session handle.
* *
* The active pad is resolved from the connected input devices (first gamepad/joystick). With none * With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
* connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the * verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
* `Log.i` receipt lines fire regardless of rendering hardware.
*/ */
class GamepadFeedback(private val handle: Long) { class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
private companion object { private companion object {
const val TAG = "pf.feedback" const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01 const val TAG_LED: Byte = 0x01
@@ -40,42 +45,48 @@ class GamepadFeedback(private val handle: Long) {
const val LEGACY_RUMBLE_MS = 60_000L const val LEGACY_RUMBLE_MS = 60_000L
} }
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 2830). */
private class RumbleBind(
val vm: VibratorManager?,
val legacy: Vibrator?,
val ids: IntArray,
val amplitudeControlled: Boolean,
)
/** One controller's lights binding (API 33+): its open session + the RGB / player-id lights it exposes. */
private class LightBind(
val session: LightsManager.LightsSession,
val rgb: Light?,
val player: Light?,
)
@Volatile private var running = false @Volatile private var running = false
private var rumbleThread: Thread? = null private var rumbleThread: Thread? = null
private var hidoutThread: Thread? = null private var hidoutThread: Thread? = null
private var vm: VibratorManager? = null // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the
// API 2830 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager // rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor. // main thread AFTER joining those threads (join establishes the happens-before), so plain maps are
private var legacy: Vibrator? = null // race-free. A null value caches "this controller has no vibrator / no controllable lights".
private var vibratorIds: IntArray = IntArray(0) private val rumbleBinds = HashMap<Int, RumbleBind?>()
private var amplitudeControlled = false private val lightBinds = HashMap<Int, LightBind?>()
private var lightsSession: LightsManager.LightsSession? = null
private var rgbLight: Light? = null
private var playerLight: Light? = null
fun start() { fun start() {
val dev = resolvePad()
bindRumble(dev)
if (Build.VERSION.SDK_INT >= 33) {
bindLights(dev)
} else {
Log.i(TAG, "lights need API 33 (have ${Build.VERSION.SDK_INT}) — lightbar/playerLed no-op")
}
running = true running = true
rumbleThread = Thread({ rumbleThread = Thread({
while (running) { while (running) {
val ev = NativeBridge.nativeNextRumble(handle) val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed if (ev < 0L) continue // timeout / closed
// ev bit 48 = has a v2 lease; bits 32..47 = ttl_ms; 16..31 = low; 0..15 = high. The // ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
// lease flag is out-of-band, so any ttl_ms (incl. 0xFFFF) is a real lease — no // 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
// in-band sentinel. No lease (legacy host) → the prior long one-shot. // 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
// long one-shot.
val pad = ((ev ushr 49) and 0xFL).toInt()
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
val ttl = ((ev ushr 32) and 0xFFFF).toInt() val ttl = ((ev ushr 32) and 0xFFFF).toInt()
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
renderRumble( renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(), ((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(), (ev and 0xFFFF).toInt(),
durationMs, durationMs,
@@ -93,100 +104,99 @@ class GamepadFeedback(private val handle: Long) {
}, "pf-hidout").apply { isDaemon = true; start() } }, "pf-hidout").apply { isDaemon = true; start() }
} }
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */ /** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
fun stop() { fun stop() {
running = false running = false
rumbleThread?.interrupt() rumbleThread?.interrupt()
hidoutThread?.interrupt() hidoutThread?.interrupt()
runCatching { vm?.cancel() } // drop any held rumble immediately
runCatching { legacy?.cancel() }
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every // Join WITHOUT a timeout. These poll threads dereference the native session handle on every
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's // pull (nativeNextRumble/nativeNextHidout) and read the router, so they MUST be dead before
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out // StreamScreen's onDispose reaches router.release() / nativeClose, which free that state. A
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the // *bounded* join that times out would let a thread survive into the freed handle → use-after-
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover). // free SIGSEGV (the back-while-streaming crash, on the one path the main-thread `closed` guard
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms) // can't cover). Safe to block unbounded: the native pulls are internally time-bounded
// and rendering is a quick best-effort binder call, so each thread observes running=false and // (PULL_TIMEOUT ~100 ms) and rendering is a quick best-effort binder call, so each thread
// exits within ~one timeout — the join returns promptly (well under any ANR threshold). // observes running=false and exits within ~one timeout — the join returns promptly.
runCatching { rumbleThread?.join() } runCatching { rumbleThread?.join() }
runCatching { hidoutThread?.join() } runCatching { hidoutThread?.join() }
rumbleThread = null rumbleThread = null
hidoutThread = null hidoutThread = null
runCatching { lightsSession?.close() } // Threads are dead — drop any held rumble and close every lights session.
lightsSession = null for (b in rumbleBinds.values) b?.let {
rgbLight = null runCatching { it.vm?.cancel() }
playerLight = null runCatching { it.legacy?.cancel() }
vm = null }
legacy = null for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
vibratorIds = IntArray(0) rumbleBinds.clear()
lightBinds.clear()
} }
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
// ---- Rumble ---- // ---- Rumble ----
private fun bindRumble(dev: InputDevice?) { /** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
if (dev == null) { private fun rumbleBindFor(pad: Int): RumbleBind? {
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)") val dev = router?.deviceForPad(pad) ?: return null
return if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
} val bind = bindRumble(dev)
rumbleBinds[dev.id] = bind
return bind
}
private fun bindRumble(dev: InputDevice): RumbleBind? {
if (Build.VERSION.SDK_INT >= 31) { if (Build.VERSION.SDK_INT >= 31) {
val m = dev.vibratorManager val m = dev.vibratorManager
val ids = m.vibratorIds val ids = m.vibratorIds
if (ids.isEmpty()) { if (ids.isEmpty()) {
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op") Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
return return null
} }
vm = m val amp = ids.all { m.getVibrator(it).hasAmplitudeControl() }
vibratorIds = ids Log.i(TAG, "rumble: bound ${ids.size} vibrators for '${dev.name}' amplitudeControl=$amp")
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() } return RumbleBind(m, null, ids, amp)
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
} else {
// API 2830: no VibratorManager — fall back to the controller's single legacy Vibrator.
@Suppress("DEPRECATION")
val v = dev.vibrator
if (!v.hasVibrator()) {
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
return
}
legacy = v
amplitudeControlled = v.hasAmplitudeControl()
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
} }
// API 2830: no VibratorManager — fall back to the controller's single legacy Vibrator.
@Suppress("DEPRECATION")
val v = dev.vibrator
if (!v.hasVibrator()) {
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
return null
}
Log.i(TAG, "rumble: bound legacy vibrator for '${dev.name}' amplitudeControl=${v.hasAmplitudeControl()}")
return RumbleBind(null, v, IntArray(0), v.hasAmplitudeControl())
} }
/** /**
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). * low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
* `durationMs` is the host's v2 envelope TTL — the one-shot self-terminates after it unless the * addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
* host renews, so a lost stop (or a dead host) silences at the lease instead of the old fixed * terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
* 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS] (the prior fixed duration). * lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
*/ */
private fun renderRumble(low: Int, high: Int, durationMs: Long) { private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low) val lo = toAmplitude(low)
val hi = toAmplitude(high) val hi = toAmplitude(high)
val m = vm val m = bind.vm
if (m != null) { if (m != null) {
if (lo == 0 && hi == 0) { if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop m.cancel() // (0,0) = stop
return return
} }
val combo = CombinedVibration.startParallel() val combo = CombinedVibration.startParallel()
if (amplitudeControlled && vibratorIds.size >= 2) { if (bind.amplitudeControlled && bind.ids.size >= 2) {
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention). // ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs)) if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs)) if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
} else { } else {
// Single motor or no amplitude control: blend both into one effect. // Single motor or no amplitude control: blend both into one effect.
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
for (id in vibratorIds) combo.addVibrator(id, oneShot(a, durationMs)) for (id in bind.ids) combo.addVibrator(id, oneShot(a, durationMs))
} }
runCatching { m.vibrate(combo.combine()) } runCatching { m.vibrate(combo.combine()) }
return return
} }
// API 2830 legacy single-motor path: blend both motors into one effect. // API 2830 legacy single-motor path: blend both motors into one effect.
val lv = legacy ?: return val lv = bind.legacy ?: return
if (lo == 0 && hi == 0) { if (lo == 0 && hi == 0) {
lv.cancel() // (0,0) = stop lv.cancel() // (0,0) = stop
return return
@@ -194,7 +204,7 @@ class GamepadFeedback(private val handle: Long) {
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching { runCatching {
lv.vibrate( lv.vibrate(
if (amplitudeControlled) oneShot(a, durationMs) if (bind.amplitudeControlled) oneShot(a, durationMs)
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
) )
} }
@@ -215,28 +225,29 @@ class GamepadFeedback(private val handle: Long) {
private fun dispatchHidout(buf: ByteBuffer, n: Int) { private fun dispatchHidout(buf: ByteBuffer, n: Int) {
buf.rewind() buf.rewind()
val pad = buf.get().toInt() and 0xFF // wire pad index the event is addressed to
when (buf.get()) { // kind tag when (buf.get()) { // kind tag
TAG_LED -> { TAG_LED -> {
val r = buf.get().toInt() and 0xFF val r = buf.get().toInt() and 0xFF
val g = buf.get().toInt() and 0xFF val g = buf.get().toInt() and 0xFF
val b = buf.get().toInt() and 0xFF val b = buf.get().toInt() and 0xFF
Log.i(TAG, "hidout Led r=$r g=$g b=$b") // verification line Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b)) if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
} }
TAG_PLAYER_LEDS -> { TAG_PLAYER_LEDS -> {
val bits = buf.get().toInt() and 0x1F val bits = buf.get().toInt() and 0x1F
val player = playerIndexForBits(bits) val player = playerIndexForBits(bits)
Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
if (Build.VERSION.SDK_INT >= 33) setPlayerId(player) if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
} }
TAG_TRIGGER -> { TAG_TRIGGER -> {
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2 val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
val effLen = n - 2 val effLen = n - 3 // [pad][kind][which] header, then the effect block
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0 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. // No public adaptive-trigger API on Android — parse-validate the mode + log only.
Log.i( Log.i(
TAG, TAG,
"hidout Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode), "hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
) )
} }
else -> Log.d(TAG, "hidout: unknown kind, dropped") else -> Log.d(TAG, "hidout: unknown kind, dropped")
@@ -253,37 +264,46 @@ class GamepadFeedback(private val handle: Long) {
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4) else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
} }
private fun bindLights(dev: InputDevice?) { /** The lights binding for the controller on wire pad [pad], or null (no live pad / no lights / < API 33). Cached by device id. */
if (dev == null) { private fun lightBindFor(pad: Int): LightBind? {
Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)") if (Build.VERSION.SDK_INT < 33) return null
return val dev = router?.deviceForPad(pad) ?: return null
} if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
val bind = bindLights(dev)
lightBinds[dev.id] = bind
return bind
}
private fun bindLights(dev: InputDevice): LightBind? {
val lm = dev.lightsManager val lm = dev.lightsManager
var rgb: Light? = null
var player: Light? = null
for (l in lm.lights) { for (l in lm.lights) {
if (rgbLight == null && l.hasRgbControl()) rgbLight = l if (rgb == null && l.hasRgbControl()) rgb = l
if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l if (player == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) player = l
} }
if (rgbLight == null && playerLight == null) { if (rgb == null && player == null) {
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op") Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
return return null
} }
lightsSession = lm.openSession() val session = lm.openSession()
Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}") Log.i(TAG, "lights: bound rgb=${rgb != null} playerLed=${player != null} for '${dev.name}'")
return LightBind(session, rgb, player)
} }
private fun setLightbar(argb: Int) { private fun setLightbar(pad: Int, argb: Int) {
val s = lightsSession ?: return val bind = lightBindFor(pad) ?: return
val l = rgbLight ?: return val l = bind.rgb ?: return
runCatching { runCatching {
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build()) bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
} }
} }
private fun setPlayerId(player: Int) { private fun setPlayerId(pad: Int, player: Int) {
val s = lightsSession ?: return val bind = lightBindFor(pad) ?: return
val l = playerLight ?: return val l = bind.player ?: return
runCatching { runCatching {
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build()) bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
} }
} }
} }
@@ -0,0 +1,204 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.input.InputManager
import android.os.Handler
import android.os.Looper
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import java.util.concurrent.ConcurrentHashMap
/**
* Multi-controller router for one stream session — the Android analogue of the Linux client's gamepad
* `Worker`/`Slot` model (`pf-client-core/src/gamepad.rs`) over the shared native-plane wire contract
* (`punktfunk-core/src/input.rs`). Each physical controller (Android `deviceId`) gets a STABLE
* lowest-free wire pad index (0..15) held for its lifetime and freed only on disconnect, so a pad
* dropping never renumbers the others (a game must not see its players shuffle). Every forwarded event
* carries that pad index; a [NativeBridge.nativeSendGamepadArrival] declaring the pad's type is sent
* once BEFORE its first input, a [NativeBridge.nativeSendGamepadRemove] on disconnect. Per-device axis
* state lives in each slot's [Gamepad.AxisMapper] so a second controller can't clobber the first.
* Feedback (rumble / HID) is routed BACK to the originating device by pad index via [deviceForPad].
*
* Selection: forward EVERY real controller (the Linux client's single-player pin has no Android UI
* surface yet — Automatic is the only mode). Lifetime matches the session: constructed on stream
* attach (opening a slot for every already-connected pad, so its Arrival lands before any input),
* released on detach.
*
* A single controller lands on wire index 0, so its per-transition button/axis wire is byte-identical
* to the old single-pad path (plus the Arrival/Remove declarations the contract requires — which an
* older host simply ignores).
*
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
* threads, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
}
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
private val slots = ConcurrentHashMap<Int, Slot>()
private val inputManager = context.getSystemService(InputManager::class.java)
private val listener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
InputDevice.getDevice(deviceId)?.let { if (isForwardable(it)) openSlot(it) }
}
override fun onInputDeviceRemoved(deviceId: Int) = closeSlot(deviceId)
override fun onInputDeviceChanged(deviceId: Int) {}
}
init {
inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
// Open a slot for every controller already connected when the session starts — the pads that
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
for (id in InputDevice.getDeviceIds()) {
InputDevice.getDevice(id)?.let { if (isForwardable(it)) openSlot(it) }
}
}
/**
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
* slot's pad index, tracks held state, and returns true when this press completed the emergency
* stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream
* (mirrors the Linux client's escape chord: any one controller can leave).
*/
fun onButton(event: KeyEvent, bit: Int): Boolean {
val slot = slotFor(event.device) ?: return false
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
if (slot.held and EXIT_CHORD == EXIT_CHORD) {
slot.held = 0
return true
}
}
KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
}
}
return false
}
/**
* One joystick MotionEvent — routed to the producing device's own [Gamepad.AxisMapper] (per-device
* state). Returns true if consumed. Only a real gamepad drives a pad: a DualSense/DS4 motion-sensor
* sibling node classifies as bare joystick (no GAMEPAD source class) and reports every pad axis as
* 0, so [isForwardable] filters it out before it can open a slot or clobber axes.
*/
fun onMotion(event: MotionEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
val dev = event.device ?: return false
if (!isForwardable(dev)) return false
val slot = slotFor(dev) ?: return false
slot.mapper.onMotion(event)
return true
}
/**
* 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.
*/
fun deviceForPad(pad: Int): InputDevice? {
for ((deviceId, slot) in slots) {
if (slot.index == pad) return InputDevice.getDevice(deviceId)
}
return null
}
/**
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]).
*/
fun release() {
inputManager?.unregisterInputDeviceListener(listener)
// Snapshot the ids first — closeSlot mutates the map.
for (id in slots.keys.toList()) closeSlot(id)
}
// ---- slots ----
/** A real, non-virtual controller we forward — its source classes include GAMEPAD (excludes a pad's bare-joystick sensor node). */
private fun isForwardable(dev: InputDevice): Boolean =
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
/**
* The slot for [dev], opening one (and declaring the pad) if this device is unseen; null when [dev]
* isn't a forwardable controller or every wire index is taken. The [isForwardable] gate lives here —
* the single lazy-open chokepoint both [onButton] and [onMotion] funnel through — so no entry point
* can open a phantom slot for a virtual/non-gamepad source (the hot-plug listener and init loop
* pre-filter and call [openSlot] directly).
*/
private fun slotFor(dev: InputDevice?): Slot? {
if (dev == null) return null
slots[dev.id]?.let { return it }
if (!isForwardable(dev)) return null
return openSlot(dev)
}
/**
* Open a slot for [dev] on the lowest free wire index, declaring its kind ([NativeBridge.nativeSendGamepadArrival])
* before any input so the host builds a matching virtual device (mixed types across pads).
* Idempotent; null when all 16 wire indices are already forwarded.
*/
private fun openSlot(dev: InputDevice): Slot? {
slots[dev.id]?.let { return it }
val index = lowestFreeIndex() ?: return null // 16 pads already forwarded — drop this one
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
// to that type (a single global choice — matches the handshake's session-default pref).
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
slots[dev.id] = slot
return slot
}
/**
* Flush a slot's held wire state (so nothing sticks host-side), signal the removal, and free its
* index. Safe against an already-gone device — the flush emits wire events only, no device access.
*/
private fun closeSlot(deviceId: Int) {
val slot = slots.remove(deviceId) ?: return
releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
}
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
private fun releaseHeld(slot: Slot) {
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
bits = bits and bit.inv()
}
slot.held = 0
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
}
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
private fun lowestFreeIndex(): Int? {
val taken = slots.values.mapTo(HashSet()) { it.index }
for (i in 0 until MAX_PADS) if (i !in taken) return i
return null
}
private companion object {
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
const val MAX_PADS = 16
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
}
}
@@ -269,26 +269,43 @@ object NativeBridge {
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */ /** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int) external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
// ---- Gamepad: one pad forwarded as pad 0 (Rust hardcodes flags=0) ---- // ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
// events into seq'd GamepadState snapshots keyed on this index and owns the per-pad seq.
/** One gamepad button transition. bit: a [Gamepad].BTN_* bit. down: press/release. */ /** One gamepad button transition on wire pad [pad] (0..15). bit: a [Gamepad].BTN_* bit. down: press/release. */
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean) external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean, pad: Int)
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */ /** One gamepad axis update on wire pad [pad] (0..15). axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int) external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int, pad: Int)
/**
* Declare the controller KIND presented on wire pad [pad] (0..15) so the host builds a matching
* virtual device (mixed types across pads). pref: a [Gamepad].PREF_* wire byte. Send ONCE when a
* pad opens, BEFORE any of its input; an older host ignores it (that pad then uses the handshake's
* session-default kind — the pre-existing single-pad behaviour on pad 0).
*/
external fun nativeSendGamepadArrival(handle: Long, pref: Int, pad: Int)
/** 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)
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ---- // ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/** /**
* Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each * Block up to ~100 ms for the next rumble update. Returns a packed positive long: bits 49..52 =
* 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread. * wire pad index (0..15), bit 48 = has a v2 lease, bits 32..47 = ttl_ms, bits 16..31 = low, bits
* 0..15 = high (each amplitude 0..0xFFFF; 0/0 = stop), or -1 on timeout / session closed. Kotlin
* routes the update to the controller holding that pad index. Call from a dedicated poll thread.
*/ */
external fun nativeNextRumble(handle: Long): Long external fun nativeNextRumble(handle: Long): Long
/** /**
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct * Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
* ByteBuffer, capacity >= 64) as `[kind][fields…]`: Led=01 r g b, PlayerLeds=02 bits, * ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
* Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed. * 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.
*/ */
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
} }
+42 -30
View File
@@ -24,12 +24,13 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
const TAG_TRIGGER: u8 = 0x03; const TAG_TRIGGER: u8 = 0x03;
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update. /// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
/// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 = /// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
/// `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = stop). The lease flag is /// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
/// out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no in-band sentinel to /// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
/// collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and Kotlin falls /// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
/// back to its long one-shot. `-1` on timeout / session closed (all packed values are positive, so /// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
/// `-1` stays unambiguous). Pad index is dropped (single-pad model). Run from a Kotlin poll thread. /// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv, _env: JNIEnv,
@@ -46,14 +47,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
// threads (and joins them — unbounded) before nativeClose frees the handle. // threads (and joins them — unbounded) before nativeClose frees the handle.
let h = unsafe { &*(handle as *const SessionHandle) }; let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble_ttl(PULL_TIMEOUT) { match h.client.next_rumble_ttl(PULL_TIMEOUT) {
Ok((_pad, low, high, ttl)) => { Ok((pad, low, high, ttl)) => {
// The reorder gate already ran in the core, so this update is fresh. Encode the // The reorder gate already ran in the core, so this update is fresh. Encode the
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. // Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
let (lease_flag, ttl_bits) = match ttl { let (lease_flag, ttl_bits) = match ttl {
Some(ms) => (1i64 << 48, jlong::from(ms) << 32), Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
None => (0, 0), None => (0, 0),
}; };
lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high) (jlong::from(pad & 0xF) << 49)
| lease_flag
| ttl_bits
| (jlong::from(low) << 16)
| jlong::from(high)
} }
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
} }
@@ -61,10 +67,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
} }
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense /// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`: /// HID-output event, written into the caller's direct ByteBuffer as `[pad][kind][fields…]` (the
/// Led → `[0x01][r][g][b]` (len 4) /// leading `pad` is the wire pad index the event is addressed to, so Kotlin routes it to that
/// PlayerLeds → `[0x02][bits]` (len 2) /// controller — multi-pad HID feedback):
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len()) /// Led → `[pad][0x01][r][g][b]` (len 5)
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small. /// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
@@ -97,33 +105,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call. // SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) }; let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
let n = match ev { let n = match ev {
HidOutput::Led { r, g, b, .. } => { HidOutput::Led { pad, r, g, b } => {
if cap < 4 { if cap < 5 {
return -1; return -1;
} }
out[0] = TAG_LED; out[0] = pad;
out[1] = r; out[1] = TAG_LED;
out[2] = g; out[2] = r;
out[3] = b; out[3] = g;
4 out[4] = b;
5
} }
HidOutput::PlayerLeds { bits, .. } => { HidOutput::PlayerLeds { pad, bits } => {
if cap < 2 { if cap < 3 {
return -1; return -1;
} }
out[0] = TAG_PLAYER_LEDS; out[0] = pad;
out[1] = bits; out[1] = TAG_PLAYER_LEDS;
2 out[2] = bits;
3
} }
HidOutput::Trigger { which, effect, .. } => { HidOutput::Trigger { pad, which, effect } => {
let n = 2 + effect.len(); let n = 3 + effect.len();
if cap < n { if cap < n {
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64 return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
} }
out[0] = TAG_TRIGGER; out[0] = pad;
out[1] = which; out[1] = TAG_TRIGGER;
out[2..n].copy_from_slice(&effect); out[2] = which;
out[3..n].copy_from_slice(&effect);
n n
} }
HidOutput::TrackpadHaptic { .. } => { HidOutput::TrackpadHaptic { .. } => {
+64 -13
View File
@@ -145,13 +145,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
} }
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input --------------- // ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the // Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id // in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
// in `code` and the value in `x` (sticks i16 32768..32767, +y = up; triggers 0..255). The host // stable lowest-free index per Android device and threads it here. Buttons carry the gamepad::BTN_*
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad. // bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id in `code` and
// the value in `x` (sticks i16 32768..32767, +y = up; triggers 0..255). The host accumulates the
// incremental events per pad into a matching virtual device. The core input task folds these into
// the seq'd GamepadState snapshots (keyed on this same `flags` index) and owns the per-pad seq — so
// the only thing this layer must get right is the index. Wire contract: input.rs::gamepad. A single
// controller lands on index 0, so its wire is byte-identical to the old single-pad path.
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition. /// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release. /// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
_env: JNIEnv, _env: JNIEnv,
@@ -159,21 +165,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
handle: jlong, handle: jlong,
bit: jint, bit: jint,
down: jboolean, down: jboolean,
pad: jint,
) { ) {
// flags = 0: pad index 0 — single-pad model.
send_event( send_event(
handle, handle,
InputKind::GamepadButton, InputKind::GamepadButton,
bit as u32, bit as u32,
i32::from(down != 0), i32::from(down != 0),
0, 0,
0, pad as u32,
); );
} }
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update. /// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (32768..32767, +y=up) or /// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
/// trigger 0..255. /// (32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
_env: JNIEnv, _env: JNIEnv,
@@ -181,7 +187,52 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
handle: jlong, handle: jlong,
axis_id: jint, axis_id: jint,
value: jint, value: jint,
pad: jint,
) { ) {
// flags = 0: pad index 0 — single-pad model. send_event(
send_event(handle, InputKind::GamepadAxis, axis_id as u32, value, 0, 0); handle,
InputKind::GamepadAxis,
axis_id as u32,
value,
0,
pad as u32,
);
}
/// `NativeBridge.nativeSendGamepadArrival(handle, pref, pad)` — declare the controller KIND presented
/// on wire pad index `pad` so the host builds a matching virtual device (mixed types — pad 0 a
/// DualSense, pad 1 an Xbox pad). `pref`: the `GamepadPref` wire byte (rides `code`). `pad`: wire pad
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pref: jint,
pad: jint,
) {
send_event(
handle,
InputKind::GamepadArrival,
pref as u32,
0,
0,
pad as u32,
);
}
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
) {
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
} }