feat(client/android): say when a captured pad's gyro can't reach the session
G8's Android half, and the last of the three clients. Same failure as the other two: a controller with a gyro, in a session whose virtual pad has no motion plane, does nothing when tilted — silently, with no way from the couch to tell that apart from a broken sensor. The fix is the Controller type setting, so the notice names it. Android read neither the requested nor the resolved backend, so this needed a plumb. What it did NOT need was a third copy of the rule. `nativePadMotionReaches` takes the kind a pad declared and answers off `pad_motion_reaches` in punktfunk-core, where the argument and the tests already live. 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. A Kotlin transcription would have been a third thing to keep in step with the host, which is exactly how the SDL half got it wrong the first time. Asked once per pad, at claim, in `openExternal` — where the pad's kind is already being declared to the host — and the answer held for the pad's lifetime on the `ExternalPad`. Not per sample: this runs at a DualSense's full report rate. `hasGyro` gates only the NOTICE, and defaults to false. `DsCapture` passes true — every pad it captures is a Sony one whose IMU is a headline feature, forwarded on the rich plane. `Sc2Capture` keeps the default, because the Steam Controller 2's motion rides inside the opaque passthrough report that `hidReport` carries, which nothing here may second-guess: warning about motion for a pad that never calls `motion()` would be a notice about a feature the player never lost. The suppression itself is on `motion()` regardless, where it costs a dead pad nothing and stops a live one paying to send samples the host will decode and discard. The notice sits at the BOTTOM of the stream overlay, unlike the mic-chord confirmation at the top. The two can coincide — a pad is claimed at roughly the moment someone might be muting — and one landing on the other would cost the user both. It holds 6 s rather than the mic chord's 1.6: that one confirms something the user just did, this one explains something they did not, in a sentence they have to read. Nulled at teardown beside `onExitArmed`/`onMicChord`, for the same reason those are — a slot closing during release must not poke Compose state on the way out. Not covered by tests, and this is a limit of the module rather than a choice: `GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric here, and the predicate it defers to is pure Rust that already has its table. So the parts that carry the reasoning are argued in comments, as `DsCapture`'s claim/teardown ordering already is. What IS mechanically verified is the piece that a compiler cannot catch and a device would fail on: the JNI symbol `Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches` is present and global in the built arm64-v8a `.so`, so the `external fun` resolves rather than throwing `UnsatisfiedLinkError` at the first pad. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (62 cases, 0 failed, read out of the JUnit XML rather than inferred from a green build — unchanged from this branch's previous count), `:app:compileDebugKotlin` and `:app:testDebugUnitTest` (67 cases, 0 failed), with `:kit:cargoNdkRelease` rebuilding the JNI crate clean across all three ABIs, plus `cargo fmt --check` on it. On-glass verification is owed on the rig the earlier legs used, and is worth doing as one pass with the two already owed there.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user