From 77797a9e2056c2d2a74adbe9f5a4cde6e73dbecc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 13:52:27 +0200 Subject: [PATCH] feat(client/pads): stop streaming gyro into a session that cannot receive it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G8 of the gyro program, SDL-client half. The `Welcome` has always carried the backend the host actually RESOLVED, which is not necessarily the one the client asked for — Auto lands on Xbox 360 for anything not Sony/Valve/Xbox, and a Switch Pro on a Windows host folds to X360 too. No client read the field. So a player with an 8BitDo, or a Switch Pro on Windows, got a controller whose gyro did nothing, with nothing anywhere saying why: the client shipped ~250 Hz of Motion datagrams and the host parsed and discarded every one. `GamepadPref::has_motion()` answers whether a backend has a motion plane at all. The SDL client checks it on the first gyro sample: it logs one line naming the resolved backend and pointing at the fix (pick a DualSense-class controller type), then stops sending. Once per slot, not per sample — this path runs at the pad's sensor rate. `Auto` deliberately answers true. It means "unknown" — an old host that omitted the echo, which may well have resolved a DualSense — and suppressing motion on unknown would silently break working gyro, a worse failure than sending datagrams nobody reads. The predicate is an exhaustive match so a new backend has to state its answer rather than inherit one, and a table test pins both halves: a false negative kills working motion, a false positive keeps the void open, and both are silent. Owed: the plan wants this surfaced as a one-line UI hint, not just a log line. Apple already stores `resolvedGamepad` and Android needs the plumb; neither is done here, and both want their own gate. Gate (Linux CI image): fmt, build, clippy --all-targets -D warnings, and the test suites — green, with the new capability test observed running. --- crates/pf-client-core/src/gamepad.rs | 22 ++++++++++ crates/punktfunk-core/src/config.rs | 60 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index e8a1d826..133ada1d 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -864,6 +864,9 @@ struct Slot { /// Gates the zero-gyro park in [`Worker::flush_slot`] — a pad with no gyro must not start /// looking like one just because it closed. sent_motion: bool, + /// The "your gyro can't reach this session" notice fired for this slot (log once, not per + /// sample — this path runs at the pad's sensor rate). + motion_unreachable_logged: bool, /// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's /// `guide_gesture` policy is on. gesture: SelectGesture, @@ -891,6 +894,7 @@ impl Slot { held_clicks: [false; 2], last_accel: [0; 3], sent_motion: false, + motion_unreachable_logged: false, gesture: SelectGesture::default(), audio_caps: 0, rumble_suppressed_logged: false, @@ -2008,6 +2012,24 @@ impl Worker { } } SensorType::Gyroscope => { + // The host echoes the backend it actually RESOLVED, which is not + // necessarily the one we asked for: an X-Box class pad has no motion plane, + // so every sample below would be decoded and dropped. Say so once — the + // player's gyro is silently doing nothing and the fix is the controller-type + // setting — and stop paying to send ~250 Hz of them. + if !c.resolved_gamepad.has_motion() { + if !slot.motion_unreachable_logged { + slot.motion_unreachable_logged = true; + tracing::warn!( + pad = slot.index, + resolved = ?c.resolved_gamepad, + "this controller has a gyro but the host session resolved a \ + backend without one — motion will not reach the game; pick a \ + DualSense-class controller type to get it" + ); + } + return; + } let mut gyro = [0i16; 3]; for (i, v) in data.iter().enumerate() { gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 8ccd2dff..55438b75 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -189,6 +189,36 @@ pub enum GamepadPref { } impl GamepadPref { + /// Whether this backend has a motion plane at all — i.e. whether a `RichInput::Motion` sample + /// sent to a host running it can reach the game, or is decoded and dropped. + /// + /// The X-Box classes have no gyro in their HID contract, so a client whose local pad HAS one + /// is streaming ~250 Hz of datagrams into a void: the host parses each and discards it, and + /// the player sees a controller whose gyro silently does nothing. Read this off + /// [`Welcome::gamepad`](crate::quic::Welcome::gamepad) — the backend the host actually + /// resolved, which is not necessarily the one the client asked for. + /// + /// `Auto` answers `true` on purpose. It means "unknown": either a host too old to echo the + /// field, or one that hasn't resolved yet. Suppressing motion on unknown would silently break + /// gyro against every old host that did resolve to a DualSense, which is a worse failure than + /// sending datagrams nobody reads. + /// + /// Exhaustive by design — a new backend has to state its answer here rather than inherit one. + pub const fn has_motion(self) -> bool { + match self { + GamepadPref::Auto => true, // unknown; assume it can, see above + GamepadPref::Xbox360 | GamepadPref::XboxOne => false, + GamepadPref::DualSense + | GamepadPref::DualShock4 + | GamepadPref::DualSenseEdge + | GamepadPref::SwitchPro + | GamepadPref::SteamController + | GamepadPref::SteamDeck + | GamepadPref::SteamController2 + | GamepadPref::SteamController2Puck => true, + } + } + /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`, /// `9 = SteamController2`, `10 = SteamController2Puck`. @@ -754,6 +784,36 @@ mod tests { assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto); } + /// Which backends a client may stream motion to. Pinned as a table because the answer decides + /// whether a player's gyro works at all, and getting it wrong in either direction is silent: + /// a false negative kills working motion, a false positive keeps ~250 Hz of datagrams flowing + /// into a host that drops every one. + #[test] + fn only_the_xbox_classes_lack_a_motion_plane() { + for p in [GamepadPref::Xbox360, GamepadPref::XboxOne] { + assert!( + !p.has_motion(), + "{} should have no motion plane", + p.as_str() + ); + } + for p in [ + GamepadPref::DualSense, + GamepadPref::DualShock4, + GamepadPref::DualSenseEdge, + GamepadPref::SwitchPro, + GamepadPref::SteamController, + GamepadPref::SteamDeck, + GamepadPref::SteamController2, + GamepadPref::SteamController2Puck, + ] { + assert!(p.has_motion(), "{} should carry motion", p.as_str()); + } + // Unknown must not suppress: an old host that omitted the echo may well have resolved a + // DualSense, and silently killing its gyro is worse than sending into a void. + assert!(GamepadPref::Auto.has_motion()); + } + #[test] fn gamepad_pref_wire_and_names() { for p in [