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:
@@ -864,6 +864,9 @@ struct Slot {
|
|||||||
/// Gates the zero-gyro park in [`Worker::flush_slot`] — a pad with no gyro must not start
|
/// Gates the zero-gyro park in [`Worker::flush_slot`] — a pad with no gyro must not start
|
||||||
/// looking like one just because it closed.
|
/// looking like one just because it closed.
|
||||||
sent_motion: bool,
|
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
|
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
|
||||||
/// `guide_gesture` policy is on.
|
/// `guide_gesture` policy is on.
|
||||||
gesture: SelectGesture,
|
gesture: SelectGesture,
|
||||||
@@ -891,6 +894,7 @@ impl Slot {
|
|||||||
held_clicks: [false; 2],
|
held_clicks: [false; 2],
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
sent_motion: false,
|
sent_motion: false,
|
||||||
|
motion_unreachable_logged: false,
|
||||||
gesture: SelectGesture::default(),
|
gesture: SelectGesture::default(),
|
||||||
audio_caps: 0,
|
audio_caps: 0,
|
||||||
rumble_suppressed_logged: false,
|
rumble_suppressed_logged: false,
|
||||||
@@ -2008,6 +2012,24 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SensorType::Gyroscope => {
|
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];
|
let mut gyro = [0i16; 3];
|
||||||
for (i, v) in data.iter().enumerate() {
|
for (i, v) in data.iter().enumerate() {
|
||||||
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
|
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
|
||||||
|
|||||||
@@ -189,6 +189,36 @@ pub enum GamepadPref {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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`,
|
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
|
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
|
||||||
/// `9 = SteamController2`, `10 = SteamController2Puck`.
|
/// `9 = SteamController2`, `10 = SteamController2Puck`.
|
||||||
@@ -754,6 +784,36 @@ mod tests {
|
|||||||
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
|
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]
|
#[test]
|
||||||
fn gamepad_pref_wire_and_names() {
|
fn gamepad_pref_wire_and_names() {
|
||||||
for p in [
|
for p in [
|
||||||
|
|||||||
Reference in New Issue
Block a user