fix(input): rock-solid held gamepad state — Android device pinning + seq'd snapshots
apple / swift (push) Successful in 1m12s
release / apple (push) Successful in 8m54s
windows-host / package (push) Successful in 7m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m49s
ci / web (push) Successful in 1m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m8s
ci / docs-site (push) Successful in 1m21s
android / android (push) Successful in 12m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 56s
decky / build-publish (push) Successful in 18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m23s
apple / screenshots (push) Successful in 5m45s
arch / build-publish (push) Successful in 11m9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m26s
flatpak / build-publish (push) Failing after 33s
ci / rust (push) Successful in 17m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m39s
docker / deploy-docs (push) Successful in 22s
deb / build-publish (push) Successful in 11m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m8s
apple / swift (push) Successful in 1m12s
release / apple (push) Successful in 8m54s
windows-host / package (push) Successful in 7m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m49s
ci / web (push) Successful in 1m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m8s
ci / docs-site (push) Successful in 1m21s
android / android (push) Successful in 12m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 56s
decky / build-publish (push) Successful in 18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m23s
apple / screenshots (push) Successful in 5m45s
arch / build-publish (push) Successful in 11m9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m26s
flatpak / build-publish (push) Failing after 33s
ci / rust (push) Successful in 17m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m39s
docker / deploy-docs (push) Successful in 22s
deb / build-publish (push) Successful in 11m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m8s
Two causes behind one field report (a held trigger jittering mid-game, Android client → Windows host): Android folded joystick ACTION_MOVEs from EVERY device into one axis state. A controller's joystick-classified sibling node (DualSense/DS4 motion sensors) or a second/drifting pad reports every pad axis as 0, so a held trigger flapped value→0→value on each event interleave. The mapper now qualifies the source DEVICE (its source classes must include GAMEPAD — a joystick event's own source is always plain JOYSTICK), pins to one deviceId until that device disconnects, and merges LTRIGGER/BRAKE (and RTRIGGER/GAS) with max, the same fold as the Controllers probe. Underneath, gamepad input rode per-transition events over unreliable, unordered QUIC datagrams — no sequence numbers, sharing the 4 KiB oldest-first-shed send buffer — so one dropped or reordered event corrupted held pad state until the NEXT change. Gamepad state now travels the way rumble already does: idempotent state, refreshed. InputKind::GamepadState packs the whole pad + a wrapping u8 seq into the existing 18-byte layout; the host advertises HOST_CAP_GAMEPAD_STATE (Welcome trailing byte, offset 67) and applies snapshots through a per-pad stale-seq gate, skipping frame emits for unchanged refreshes; the client folds embedder events into snapshots inside NativeClient's input task (send on change + 100 ms refresh of touched pads), so the SDL clients (Linux/Windows/session), Android, and Apple (C ABI) are all covered with zero capture-code changes. Either end older ⇒ the legacy per-transition path runs unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -425,7 +425,7 @@ impl InputInjector for KwinFakeInjector {
|
||||
self.fake.touch_frame();
|
||||
}
|
||||
// Gamepads are injected through uinput, not the compositor.
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => {}
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
|
||||
@@ -403,6 +403,7 @@ fn kind_bit(kind: InputKind) -> u32 {
|
||||
InputKind::TouchUp => 9,
|
||||
InputKind::GamepadButton => 10,
|
||||
InputKind::GamepadAxis => 11,
|
||||
InputKind::GamepadState => 12,
|
||||
};
|
||||
1 << i
|
||||
}
|
||||
@@ -545,7 +546,7 @@ impl EiState {
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
||||
DeviceCapability::Touch
|
||||
}
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
||||
};
|
||||
self.injected += 1;
|
||||
let n = self.injected;
|
||||
@@ -692,7 +693,9 @@ impl EiState {
|
||||
Some(t) => t.up(ev.code),
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false,
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {
|
||||
emitted = false
|
||||
}
|
||||
}
|
||||
|
||||
if emitted {
|
||||
|
||||
@@ -254,7 +254,7 @@ impl InputInjector for WlrootsInjector {
|
||||
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
||||
}
|
||||
}
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||
}
|
||||
|
||||
@@ -297,9 +297,10 @@ impl InputInjector for SendInputInjector {
|
||||
};
|
||||
self.send(&[key(ki)])
|
||||
}
|
||||
// Gamepad goes through ViGEm (separate backend). Touch: no SendInput equivalent -> no-op.
|
||||
// Gamepad goes through the XUSB backend. Touch: no SendInput equivalent -> no-op.
|
||||
InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadState
|
||||
| InputKind::TouchDown
|
||||
| InputKind::TouchMove
|
||||
| InputKind::TouchUp => Ok(()),
|
||||
|
||||
@@ -1045,6 +1045,9 @@ async fn serve_session(
|
||||
// HEVC-precedence tie-break). The client builds its decoder from this instead of
|
||||
// assuming HEVC.
|
||||
codec: codec_bit,
|
||||
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
|
||||
// so capable clients send those instead of the loss-fragile per-transition events.
|
||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
|
||||
};
|
||||
io::write_msg(&mut send, &welcome.encode()).await?;
|
||||
|
||||
@@ -1544,7 +1547,8 @@ async fn serve_session(
|
||||
|
||||
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
|
||||
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
/// A snapshot-capable client replaces the whole state at once ([`PadState::set_snapshot`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct PadState {
|
||||
buttons: u32,
|
||||
left_trigger: u8,
|
||||
@@ -1581,6 +1585,17 @@ impl PadState {
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the whole state from one client snapshot (the [`InputKind::GamepadState`] form).
|
||||
fn set_snapshot(&mut self, s: &punktfunk_core::input::GamepadSnapshot) {
|
||||
self.buttons = s.buttons;
|
||||
self.left_trigger = s.left_trigger;
|
||||
self.right_trigger = s.right_trigger;
|
||||
self.ls_x = s.ls_x;
|
||||
self.ls_y = s.ls_y;
|
||||
self.rs_x = s.rs_x;
|
||||
self.rs_y = s.rs_y;
|
||||
}
|
||||
|
||||
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
|
||||
crate::gamestream::gamepad::GamepadFrame {
|
||||
index: index as i16,
|
||||
@@ -1596,9 +1611,9 @@ impl PadState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest pad index addressable on the wire (`flags` field); the uinput manager caps
|
||||
/// actual pad creation at its own MAX_PADS.
|
||||
const MAX_WIRE_PADS: usize = 16;
|
||||
/// Highest pad index addressable on the wire (`flags` field / snapshot `pad`); the uinput
|
||||
/// manager caps actual pad creation at its own MAX_PADS.
|
||||
const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
||||
/// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The
|
||||
@@ -1800,6 +1815,9 @@ fn input_thread(
|
||||
let mut motion_window = std::time::Instant::now();
|
||||
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
|
||||
let mut pad_mask = 0u16;
|
||||
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
|
||||
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
|
||||
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
|
||||
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
|
||||
// so re-send the current state of every rumbling-capable pad every 500 ms — a dropped
|
||||
// transition (including a stop) heals on the next refresh.
|
||||
@@ -1866,6 +1884,32 @@ fn input_thread(
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
||||
}
|
||||
}
|
||||
InputKind::GamepadState => {
|
||||
// Idempotent full-state snapshot from a capable client (see
|
||||
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
|
||||
// a datagram the network reordered can't roll held state backwards. The
|
||||
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
|
||||
// the common case — skip the frame emit then (an XInput packet-number bump
|
||||
// for identical state is pure churn), but always advance the gate.
|
||||
use punktfunk_core::input::GamepadSnapshot;
|
||||
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
|
||||
let idx = snap.pad as usize;
|
||||
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
|
||||
{
|
||||
pad_seq[idx] = Some(snap.seq);
|
||||
let before = pad_state[idx];
|
||||
pad_state[idx].set_snapshot(&snap);
|
||||
let first = pad_mask & (1 << idx) == 0;
|
||||
if first || pad_state[idx] != before {
|
||||
pad_mask |= 1 << idx;
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(
|
||||
frame,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
match ev.kind {
|
||||
@@ -4322,6 +4366,65 @@ fn build_pipeline(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pad_snapshot_replaces_state_and_seq_gates() {
|
||||
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
||||
let mut state = PadState::default();
|
||||
let mut last_seq: Option<u8> = None;
|
||||
|
||||
// Legacy accumulation first (an older client), then a snapshot replaces it wholesale.
|
||||
let axis = InputEvent {
|
||||
kind: InputKind::GamepadAxis,
|
||||
_pad: [0; 3],
|
||||
code: gamepad::AXIS_LT,
|
||||
x: 200,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
assert!(state.apply(&axis));
|
||||
assert_eq!(state.left_trigger, 200);
|
||||
|
||||
let snap = GamepadSnapshot {
|
||||
pad: 0,
|
||||
seq: 1,
|
||||
buttons: gamepad::BTN_A,
|
||||
left_trigger: 255,
|
||||
right_trigger: 0,
|
||||
ls_x: 100,
|
||||
ls_y: -100,
|
||||
rs_x: 0,
|
||||
rs_y: 0,
|
||||
};
|
||||
assert!(GamepadSnapshot::seq_newer(snap.seq, last_seq));
|
||||
last_seq = Some(snap.seq);
|
||||
state.set_snapshot(&snap);
|
||||
assert_eq!(state.left_trigger, 255);
|
||||
assert_eq!(state.buttons, gamepad::BTN_A);
|
||||
assert_eq!((state.ls_x, state.ls_y), (100, -100));
|
||||
|
||||
// A reordered (stale) snapshot must not roll the trigger back.
|
||||
let stale = GamepadSnapshot {
|
||||
seq: 0,
|
||||
left_trigger: 10,
|
||||
..snap
|
||||
};
|
||||
assert!(!GamepadSnapshot::seq_newer(stale.seq, last_seq));
|
||||
|
||||
// The unchanged-refresh case the input thread skips the frame emit for: identical
|
||||
// payload with a newer seq compares equal after apply.
|
||||
let refresh = GamepadSnapshot { seq: 2, ..snap };
|
||||
assert!(GamepadSnapshot::seq_newer(refresh.seq, last_seq));
|
||||
let before = state;
|
||||
state.set_snapshot(&refresh);
|
||||
assert_eq!(state, before);
|
||||
|
||||
// The snapshot survives the wire roundtrip into the same PadState shape.
|
||||
let dec =
|
||||
GamepadSnapshot::from_event(&InputEvent::decode(&snap.to_event().encode()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(dec, snap);
|
||||
}
|
||||
|
||||
/// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return
|
||||
/// what each `note` produced.
|
||||
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> {
|
||||
|
||||
Reference in New Issue
Block a user