feat(client/pads): stop streaming gyro into a session that cannot receive it

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.
This commit is contained in:
2026-08-07 13:52:27 +02:00
parent ce5047f3ad
commit 77797a9e20
2 changed files with 82 additions and 0 deletions
+22
View File
@@ -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;
+60
View File
@@ -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 [