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 600804b7..b52a8b56 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 @@ -137,6 +137,19 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U micHint = null } } + // A captured pad has a gyro this session's virtual controller cannot carry (see + // GamepadRouter.onMotionUnreachable). Shown briefly, then gone: the failure is otherwise + // completely silent — the gyro simply does nothing, which from the couch is indistinguishable + // from a broken sensor — and the fix is a setting, so the notice has to name it. + var motionHint by remember { mutableStateOf(false) } + LaunchedEffect(motionHint) { + if (motionHint) { + // Longer than the mic chord's 1.6 s: that one confirms something the user just did, + // this one explains something they did not, in a sentence they have to read. + delay(6000) + motionHint = false + } + } // The one place mute is toggled — Compose state + the native flag, always together. val setMicMuted = { muted: Boolean -> micMuted = muted @@ -359,6 +372,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // Select + Y toggles the mic — the couch reach for the on-screen mute button, which a // gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing // to mute, and claiming otherwise would be the lie the control exists to avoid). + // A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the + // moment it is claimed, on the main thread. + router.onMotionUnreachable = { motionHint = true } router.onMicChord = { if (micRunning) { val next = !micMuted @@ -593,6 +609,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down router.onMicChord = null // same: no mute toggle on buttons released during teardown + router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener activity?.gamepadRouter = null // Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor. @@ -853,6 +870,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U } // Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger. micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) } + // Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a + // notice landing on top of one of those would cost the user both. + if (motionHint) { + MotionUnreachableHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp)) + } } } @@ -939,6 +961,28 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) { ) } +/** + * "This pad's gyro can't reach the game" — shown briefly when a captured controller with motion + * meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their + * HID contract, so every sample would be decoded and dropped host-side). + * + * It names the setting because that is the whole point: without it the player has a gyro that + * silently does nothing and no way to tell that from a broken sensor. Not a control — the setting + * applies from the next session, so offering to change it here would promise something this stream + * cannot deliver. [GamepadRouter.onMotionUnreachable] raises it. + */ +@Composable +private fun MotionUnreachableHint(modifier: Modifier = Modifier) { + Text( + "Motion won't reach this session — set Controller type to DualSense", + modifier = modifier + .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White, + fontSize = 15.sp, + ) +} + /** * The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The * chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 789cb214..58406de4 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -289,7 +289,9 @@ class DsCapture( @Synchronized private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? { pad?.let { return it } - val p = router.openExternal(m.pref) ?: return null + // hasGyro: every pad this link captures is a Sony one with an IMU, and its motion goes out + // on the rich plane — so a session that cannot carry it is worth saying out loud. + val p = router.openExternal(m.pref, hasGyro = true) ?: return null pad = p Log.i(TAG, "captured $m → wire pad ${p.index}") // The wire index exists from here on, and the host addresses pad audio by it. 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 index 7fe1f02d..ff4b693f 100644 --- 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 @@ -115,6 +115,17 @@ class GamepadRouter( */ var onMicChord: (() -> Unit)? = null + /** + * Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in + * a session whose virtual pad has no motion plane — its motion is not being sent, because every + * sample would be decoded and dropped host-side. + * + * It exists because the failure is otherwise completely silent: the gyro just does nothing, and + * from the couch that is indistinguishable from a broken sensor. The fix is the Controller type + * setting, so whatever shows this has to name it. `StreamScreen` wires it to a brief notice. + */ + var onMotionUnreachable: (() -> Unit)? = null + private val mainHandler = Handler(Looper.getMainLooper()) /** The pending exit-chord hold timer, or null when the chord isn't currently armed. */ private var pendingExit: Runnable? = null @@ -326,7 +337,18 @@ class GamepadRouter( * the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state * flush + Remove on [close], and full participation in the emergency exit chord. */ - inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) { + inner class ExternalPad internal constructor( + private val syntheticId: Int, + val index: Int, + /** + * Whether this pad's motion can reach the game at all, asked once at open (see + * [NativeBridge.nativePadMotionReaches]). False means the host built this pad a backend + * without a motion plane, so [motion] drops the sample here instead of paying to send one + * the host will decode and discard — at a controller's full report rate, for the whole + * session. + */ + private val motionReaches: Boolean, + ) { // Live lookup instead of a captured reference: after [close] (or a router release) the // slot is gone from the table and every entry point below degrades to a safe no-op. private val slot get() = slots[syntheticId] @@ -357,7 +379,7 @@ class GamepadRouter( /** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16 * units — the host passes them straight into the virtual pad's report). Per report. */ fun motion(gyro: IntArray, accel: IntArray) { - if (slot != null && forwarding) { + if (slot != null && forwarding && motionReaches) { NativeBridge.nativeSendPadMotion( handle, index, gyro[0], gyro[1], gyro[2], @@ -373,15 +395,26 @@ class GamepadRouter( /** * Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire * indices are taken. Main thread (like the hot-plug callbacks). + * + * [hasGyro] says whether this link forwards motion on the RICH plane ([ExternalPad.motion]) — + * true for the Sony pads, whose IMU is a headline feature, and false for the Steam Controller 2, + * whose motion rides inside the opaque passthrough report that [ExternalPad.hidReport] carries + * and which nothing here may second-guess. It gates only the notice: a pad that never sends + * motion must not produce a warning about motion. */ - fun openExternal(pref: Int): ExternalPad? { + fun openExternal(pref: Int, hasGyro: Boolean = false): ExternalPad? { val index = lowestFreeIndex() ?: return null // Synthetic ids live below any real InputDevice id (those are positive), so they can't // collide and InputDevice.getDevice(id) resolves them to null for the feedback path. val syntheticId = EXTERNAL_ID_BASE - index if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index) + // Asked once, here, off the kind this pad just DECLARED — not off the session's resolved + // backend, which under Automatic answers for whichever pad happened to be active at dial + // time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime. + val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref) + if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke() slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index)) - return ExternalPad(syntheticId, index) + return ExternalPad(syntheticId, index, motionReaches) } /** 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 77513e1f..07ac6c30 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 @@ -516,6 +516,23 @@ object NativeBridge { /** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */ external fun nativeSendGamepadRemove(handle: Long, pad: Int) + /** + * Whether motion sent for a pad that declared [declaredPref] (the [Gamepad].PREF_* byte passed + * to [nativeSendGamepadArrival]) can actually reach the game, or would be decoded and dropped + * by a host backend without a motion plane — the X-Box classes have no gyro in their HID + * contract. + * + * Answered natively, off `punktfunk_core::config::pad_motion_reaches`, rather than + * reconstructed here from the session's requested/resolved prefs. The rule is subtler than it + * looks (the host builds each pad from its OWN declaration and folds what it cannot build, so + * neither the declaration nor the session echo answers it alone) and every way of getting it + * wrong is silent, so it lives in one place with one set of tests. + * + * Ask ONCE when a pad opens, not per sample. `true` when the session handle is dead — "don't + * suppress" is the safe answer whenever we cannot tell. + */ + external fun nativePadMotionReaches(handle: Long, declaredPref: Int): Boolean + /** * One raw HID input report from a client-captured controller (the as-is Steam Controller 2 * passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose diff --git a/clients/android/native/src/session/input.rs b/clients/android/native/src/session/input.rs index 4a01acb6..d5804b1f 100644 --- a/clients/android/native/src/session/input.rs +++ b/clients/android/native/src/session/input.rs @@ -361,6 +361,40 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad ); } +/// `NativeBridge.nativePadMotionReaches(handle, declaredPref)` — whether motion sent for a pad that +/// declared `declaredPref` (the `GamepadPref` wire byte it passed to `nativeSendGamepadArrival`) can +/// actually reach the game, or would be decoded and dropped by a host backend with no motion plane. +/// +/// The whole question is answered here rather than in Kotlin so the reasoning lives in exactly one +/// place — [`punktfunk_core::config::pad_motion_reaches`], which carries the argument and the tests. +/// A third transcription of it would be a third thing to get subtly wrong, and every way of getting +/// it wrong is silent: too strict kills a working gyro, too lax keeps ~250 Hz of samples flowing +/// into a host that drops every one. +/// +/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching +/// the `Auto` rule inside the predicate itself. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches( + _env: JNIEnv, + _this: JObject, + handle: jlong, + declared_pref: jint, +) -> jboolean { + if handle == 0 { + return 1; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy + // values read behind `&self`. + let h = unsafe { &*(handle as *const SessionHandle) }; + let declared = + punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8); + u8::from(punktfunk_core::config::pad_motion_reaches( + declared, + h.client.requested_gamepad, + h.client.resolved_gamepad, + )) +} + /// `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