diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index e567b187..02a68d66 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -158,8 +158,11 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - pads.forEachIndexed { i, dev -> - PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting) + // Every real controller is forwarded now (Automatic forwards them all, each on its own + // 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) { Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) 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( - "forwarded to host", + if (number > 0) "forwarded · player $number" else "forwarded to host", style = MaterialTheme.typography.labelSmall, 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. */ private fun deviceHasVibrator(dev: InputDevice): Boolean = if (Build.VERSION.SDK_INT >= 31) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 330d3aa3..a98f48a2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import io.unom.punktfunk.kit.Gamepad +import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.Keymap import io.unom.punktfunk.kit.NativeBridge @@ -27,8 +28,12 @@ class MainActivity : ComponentActivity() { */ 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]). @@ -44,9 +49,6 @@ class MainActivity : ComponentActivity() { */ 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 * 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)) { val bit = Gamepad.buttonBit(event.keyCode) if (bit != 0) { - when (event.action) { - // repeatCount guard: don't re-send a held button as auto-repeat. - KeyEvent.ACTION_DOWN -> { - if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true) - heldPadButtons = heldPadButtons or bit - // 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() - } + // The router forwards the bit on this device's own wire pad index, tracks held + // state per pad, and reports when the emergency-exit chord (Select + Start + L1 + + // R1) completed on any one pad (a couch user has no keyboard/Back). + if (gamepadRouter?.onButton(event, bit) == true) { + requestStreamExit?.let { exit -> window.decorView.post { exit() } } } return true // consumed } @@ -203,7 +194,7 @@ class MainActivity : ComponentActivity() { override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { if (streamHandle != 0L) { - if (axisMapper?.onMotion(event) == true) return true + if (gamepadRouter?.onMotion(event) == true) return true return super.dispatchGenericMotionEvent(event) } // The Controllers debug screen sees pad motion before the stick→D-pad synthesis below. @@ -248,9 +239,4 @@ class MainActivity : ComponentActivity() { -> true 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 - } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 202128fb..83444567 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -32,8 +32,8 @@ import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.GamepadFeedback +import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.VideoDecoders import java.util.concurrent.atomic.AtomicBoolean @@ -174,18 +174,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { val priorOrientation = activity?.requestedOrientation activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE 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 // the keep-alive linger), unlike a host-ended / backgrounded drop. activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate - // Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close. - val feedback = GamepadFeedback(handle).also { it.start() } + // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad + // 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 { 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 - activity?.axisMapper?.reset() // release-all so nothing sticks on the host - activity?.axisMapper = null + feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed + router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener + activity?.gamepadRouter = null activity?.streamHandle = 0L activity?.requestStreamExit = null activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 0f06bc74..608016c6 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -171,47 +171,26 @@ object Gamepad { } /** - * Maps joystick MotionEvents to axis (+ HAT→dpad) sends for one session, **on change only**. - * Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per - * session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing - * sticks on the host (which has no client-side held-state knowledge). + * Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad], + * **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One + * instance per forwarded controller (owned by [GamepadRouter], which routes each device's events + * 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 - * whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the - * first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion - * sensors) and any second pad report every axis as 0, and folding them into the same state - * flapped a held trigger/stick between its value and 0 on every event interleave. + * The router only ever feeds this a qualifying event from the mapper's own device — a real + * gamepad (its source classes include GAMEPAD), never a controller's joystick-classified sibling + * node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore + * folds the event straight in without re-qualifying it. */ - 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). private val last = IntArray(6) { Int.MIN_VALUE } private var hatX = 0 // -1 / 0 / +1 private var hatY = 0 - /** deviceId of the controller pad 0 is pinned to; −1 until the first qualifying event. */ - private var deviceId = -1 - - /** 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 - } - + /** Fold one joystick ACTION_MOVE from this mapper's controller onto its pad index. */ + fun onMotion(event: MotionEvent) { // 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_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) 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() { 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) @@ -268,10 +246,10 @@ object Gamepad { private fun sendAxis(id: Int, v: Int) { if (last[id] == v) return 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). private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt() diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index 46f2b122..3d731053 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -15,21 +15,26 @@ import android.view.InputDevice import java.nio.ByteBuffer /** - * Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll - * threads drain the blocking native pulls and render in Kotlin: rumble → the controller's - * `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar / - * player-LED via `LightsManager` (API 33+); adaptive - * triggers are parse-validated and logged (Android has no public adaptive-trigger API). + * Host→client gamepad feedback for one session, routed per controller by wire pad index. Two daemon + * poll threads drain the blocking native pulls and render in Kotlin: rumble → the addressed + * controller's `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output + * → that controller's lightbar / player-LED via `LightsManager` (API 33+); adaptive triggers are + * 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 * 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 - * connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the - * `Log.i` receipt lines fire regardless of rendering hardware. + * With no controller connected (emulator) rumble/lights become logged no-ops — exactly the + * verification path; the `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 { const val TAG = "pf.feedback" const val TAG_LED: Byte = 0x01 @@ -40,42 +45,48 @@ class GamepadFeedback(private val handle: Long) { const val LEGACY_RUMBLE_MS = 60_000L } + /** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */ + 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 private var rumbleThread: Thread? = null private var hidoutThread: Thread? = null - private var vm: VibratorManager? = null - // API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager - // until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor. - private var legacy: Vibrator? = null - private var vibratorIds: IntArray = IntArray(0) - private var amplitudeControlled = false - - private var lightsSession: LightsManager.LightsSession? = null - private var rgbLight: Light? = null - private var playerLight: Light? = null + // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the + // rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the + // main thread AFTER joining those threads (join establishes the happens-before), so plain maps are + // race-free. A null value caches "this controller has no vibrator / no controllable lights". + private val rumbleBinds = HashMap() + private val lightBinds = HashMap() 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 rumbleThread = Thread({ while (running) { val ev = NativeBridge.nativeNextRumble(handle) 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 - // lease flag is out-of-band, so any ttl_ms (incl. 0xFFFF) is a real lease — no - // in-band sentinel. No lease (legacy host) → the prior long one-shot. + // ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms; + // 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl. + // 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 ttl = ((ev ushr 32) and 0xFFFF).toInt() val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS renderRumble( + pad, ((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt(), durationMs, @@ -93,100 +104,99 @@ class GamepadFeedback(private val handle: Long) { }, "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() { running = false rumbleThread?.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 - // pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's - // onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out - // would let a thread survive into the freed handle → use-after-free SIGSEGV (the - // back-while-streaming crash, on the one path the main-thread `closed` guard can't cover). - // Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms) - // and rendering is a quick best-effort binder call, so each thread observes running=false and - // exits within ~one timeout — the join returns promptly (well under any ANR threshold). + // pull (nativeNextRumble/nativeNextHidout) and read the router, so they MUST be dead before + // StreamScreen's onDispose reaches router.release() / nativeClose, which free that state. A + // *bounded* join that times out would let a thread survive into the freed handle → use-after- + // free SIGSEGV (the back-while-streaming crash, on the one path the main-thread `closed` guard + // can't cover). Safe to block unbounded: the native pulls are internally time-bounded + // (PULL_TIMEOUT ~100 ms) and rendering is a quick best-effort binder call, so each thread + // observes running=false and exits within ~one timeout — the join returns promptly. runCatching { rumbleThread?.join() } runCatching { hidoutThread?.join() } rumbleThread = null hidoutThread = null - runCatching { lightsSession?.close() } - lightsSession = null - rgbLight = null - playerLight = null - vm = null - legacy = null - vibratorIds = IntArray(0) + // Threads are dead — drop any held rumble and close every lights session. + for (b in rumbleBinds.values) b?.let { + runCatching { it.vm?.cancel() } + runCatching { it.legacy?.cancel() } + } + for (b in lightBinds.values) b?.let { runCatching { it.session.close() } } + rumbleBinds.clear() + lightBinds.clear() } - /** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */ - private fun resolvePad(): InputDevice? = Gamepad.firstPad() - // ---- Rumble ---- - private fun bindRumble(dev: InputDevice?) { - if (dev == null) { - Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)") - return - } + /** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */ + private fun rumbleBindFor(pad: Int): RumbleBind? { + val dev = router?.deviceForPad(pad) ?: return null + 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) { val m = dev.vibratorManager val ids = m.vibratorIds if (ids.isEmpty()) { Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op") - return + return null } - vm = m - vibratorIds = ids - amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() } - Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled") - } else { - // API 28–30: 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") + val amp = ids.all { m.getVibrator(it).hasAmplitudeControl() } + Log.i(TAG, "rumble: bound ${ids.size} vibrators for '${dev.name}' amplitudeControl=$amp") + return RumbleBind(m, null, ids, amp) } + // API 28–30: 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). - * `durationMs` is the host's v2 envelope TTL — the one-shot self-terminates after it unless the - * host renews, so a lost stop (or a dead host) silences at the lease instead of the old fixed - * 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS] (the prior fixed duration). + * low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes), + * addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self- + * terminates after it unless the host renews, so a lost stop (or a dead host) silences at the + * 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) { - Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return + private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) { + 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 hi = toAmplitude(high) - val m = vm + val m = bind.vm if (m != null) { if (lo == 0 && hi == 0) { m.cancel() // (0,0) = stop return } 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). - if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs)) - if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs)) + if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs)) + if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs)) } else { // Single motor or no amplitude control: blend both into one effect. 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()) } return } // API 28–30 legacy single-motor path: blend both motors into one effect. - val lv = legacy ?: return + val lv = bind.legacy ?: return if (lo == 0 && hi == 0) { lv.cancel() // (0,0) = stop return @@ -194,7 +204,7 @@ class GamepadFeedback(private val handle: Long) { val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) runCatching { lv.vibrate( - if (amplitudeControlled) oneShot(a, durationMs) + if (bind.amplitudeControlled) oneShot(a, durationMs) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs) ) } @@ -215,28 +225,29 @@ class GamepadFeedback(private val handle: Long) { private fun dispatchHidout(buf: ByteBuffer, n: Int) { buf.rewind() + val pad = buf.get().toInt() and 0xFF // wire pad index the event is addressed to when (buf.get()) { // kind tag TAG_LED -> { val r = buf.get().toInt() and 0xFF val g = 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 - if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b)) + 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)) } TAG_PLAYER_LEDS -> { val bits = buf.get().toInt() and 0x1F val player = playerIndexForBits(bits) - Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line - if (Build.VERSION.SDK_INT >= 33) setPlayerId(player) + Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line + 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 - 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 // No public adaptive-trigger API on Android — parse-validate the mode + log only. Log.i( 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") @@ -253,37 +264,46 @@ class GamepadFeedback(private val handle: Long) { else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4) } - private fun bindLights(dev: InputDevice?) { - if (dev == null) { - Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)") - return - } + /** The lights binding for the controller on wire pad [pad], or null (no live pad / no lights / < API 33). Cached by device id. */ + private fun lightBindFor(pad: Int): LightBind? { + if (Build.VERSION.SDK_INT < 33) return null + 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 + var rgb: Light? = null + var player: Light? = null for (l in lm.lights) { - if (rgbLight == null && l.hasRgbControl()) rgbLight = l - if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l + if (rgb == null && l.hasRgbControl()) rgb = 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") - return + return null } - lightsSession = lm.openSession() - Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}") + val session = lm.openSession() + Log.i(TAG, "lights: bound rgb=${rgb != null} playerLed=${player != null} for '${dev.name}'") + return LightBind(session, rgb, player) } - private fun setLightbar(argb: Int) { - val s = lightsSession ?: return - val l = rgbLight ?: return + private fun setLightbar(pad: Int, argb: Int) { + val bind = lightBindFor(pad) ?: return + val l = bind.rgb ?: return 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) { - val s = lightsSession ?: return - val l = playerLight ?: return + private fun setPlayerId(pad: Int, player: Int) { + val bind = lightBindFor(pad) ?: return + val l = bind.player ?: return 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()) } } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt new file mode 100644 index 00000000..2e3dee95 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -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() + + 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 + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 25992812..14b5aad9 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -269,26 +269,43 @@ object NativeBridge { /** 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) - // ---- 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. */ - external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean) + /** 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, pad: Int) - /** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */ - external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int) + /** 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, 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) ---- /** - * Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each - * 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread. + * Block up to ~100 ms for the next rumble update. Returns a packed positive long: bits 49..52 = + * wire pad index (0..15), bit 48 = has a v2 lease, 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 /** * 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, - * Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed. + * ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to + * route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the + * byte count, or -1 on timeout / session closed. */ external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int } diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index f5782276..605c278a 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -24,12 +24,13 @@ const TAG_PLAYER_LEDS: u8 = 0x02; const TAG_TRIGGER: u8 = 0x03; /// `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 = -/// `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = stop). The lease flag is -/// out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no in-band sentinel to -/// collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and Kotlin falls -/// back to its long one-shot. `-1` on timeout / session closed (all packed values are positive, so -/// `-1` stays unambiguous). Pad index is dropped (single-pad model). Run from a Kotlin poll thread. +/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease", +/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = +/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no +/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and +/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are +/// 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] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( _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. let h = unsafe { &*(handle as *const SessionHandle) }; 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 - // 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 { Some(ms) => (1i64 << 48, jlong::from(ms) << 32), 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 } @@ -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 -/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`: -/// Led → `[0x01][r][g][b]` (len 4) -/// PlayerLeds → `[0x02][bits]` (len 2) -/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len()) +/// HID-output event, written into the caller's direct ByteBuffer as `[pad][kind][fields…]` (the +/// leading `pad` is the wire pad index the event is addressed to, so Kotlin routes it to that +/// controller — multi-pad HID feedback): +/// 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. #[no_mangle] 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. 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 { - HidOutput::Led { r, g, b, .. } => { - if cap < 4 { + HidOutput::Led { pad, r, g, b } => { + if cap < 5 { return -1; } - out[0] = TAG_LED; - out[1] = r; - out[2] = g; - out[3] = b; - 4 + out[0] = pad; + out[1] = TAG_LED; + out[2] = r; + out[3] = g; + out[4] = b; + 5 } - HidOutput::PlayerLeds { bits, .. } => { - if cap < 2 { + HidOutput::PlayerLeds { pad, bits } => { + if cap < 3 { return -1; } - out[0] = TAG_PLAYER_LEDS; - out[1] = bits; - 2 + out[0] = pad; + out[1] = TAG_PLAYER_LEDS; + out[2] = bits; + 3 } - HidOutput::Trigger { which, effect, .. } => { - let n = 2 + effect.len(); + HidOutput::Trigger { pad, which, effect } => { + let n = 3 + effect.len(); if cap < n { return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64 } - out[0] = TAG_TRIGGER; - out[1] = which; - out[2..n].copy_from_slice(&effect); + out[0] = pad; + out[1] = TAG_TRIGGER; + out[2] = which; + out[3..n].copy_from_slice(&effect); n } HidOutput::TrackpadHaptic { .. } => { diff --git a/clients/android/native/src/session/input.rs b/clients/android/native/src/session/input.rs index 36285496..54c45784 100644 --- a/clients/android/native/src/session/input.rs +++ b/clients/android/native/src/session/input.rs @@ -145,13 +145,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey( } // ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input --------------- -// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the -// gamepad::BTN_* 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 into its virtual xpad. Wire contract: input.rs::gamepad. +// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried +// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a +// stable lowest-free index per Android device and threads it here. Buttons carry the gamepad::BTN_* +// 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. -/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release. +/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on +/// 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] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton( _env: JNIEnv, @@ -159,21 +165,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad handle: jlong, bit: jint, down: jboolean, + pad: jint, ) { - // flags = 0: pad index 0 — single-pad model. send_event( handle, InputKind::GamepadButton, bit as u32, i32::from(down != 0), 0, - 0, + pad as u32, ); } -/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update. -/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (−32768..32767, +y=up) or -/// trigger 0..255. +/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire +/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 +/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`). #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis( _env: JNIEnv, @@ -181,7 +187,52 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad handle: jlong, axis_id: jint, value: jint, + pad: jint, ) { - // flags = 0: pad index 0 — single-pad model. - send_event(handle, InputKind::GamepadAxis, axis_id as u32, value, 0, 0); + send_event( + 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); }