From 7d5dbd47b7518776beca47f3671a3cd96f1953f6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 07:37:49 +0000 Subject: [PATCH] fix(host/dualsense): heartbeat virtual DualSense so it isn't dropped when idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Controller disconnected every few seconds" (Forza Horizon, held steady): the virtual UHID DualSense emitted HID report 0x01 ONLY on state change, but a real DualSense streams it continuously (~250 Hz). When the player holds the wheel/throttle steady the client sends no wire events, so the host wrote nothing and /dev/uhid went silent for seconds — the kernel hid-playstation driver / Proton / SDL treat that as an unplugged controller. (The uinput X-Box pad is immune: evdev holds last-known state with no periodic-report requirement.) Add DualSenseManager::heartbeat(max_gap): re-emit each live pad's CURRENT report when it's been silent for max_gap (idempotent — a stale-but-correct frame, never a phantom input; write_state bumps seq+timestamp). write() resets the per-pad timer, so an actively-used pad emits no extra reports — the heartbeat only fills genuine silence. PadBackend::heartbeat() drives it at an 8 ms gap (~125 Hz) for DualSense (no-op for X-Box), called every input-thread tick (the loop already runs ≤4 ms). GET_REPORT feature replies + the pad lifecycle were ruled out by the investigation (pad is created once, never torn down mid-session). Compiles, clippy/fmt clean, 78 host tests pass. Verify on the box: held-idle DualSense stays present in evtest / no SDL CONTROLLERDEVICEREMOVED; Forza no longer toasts "controller disconnected". Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/inject/dualsense.rs | 24 +++++++++++++++++++ crates/punktfunk-host/src/m3.rs | 17 +++++++++++++ 2 files changed, 41 insertions(+) diff --git a/crates/punktfunk-host/src/inject/dualsense.rs b/crates/punktfunk-host/src/inject/dualsense.rs index dfc296e..202e8a3 100644 --- a/crates/punktfunk-host/src/inject/dualsense.rs +++ b/crates/punktfunk-host/src/inject/dualsense.rs @@ -17,6 +17,7 @@ use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; +use std::time::{Duration, Instant}; // /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a // union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372). @@ -496,6 +497,9 @@ pub struct DualSenseManager { state: Vec, /// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it. last_rumble: Vec<(u16, u16)>, + /// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which + /// re-emits the current state during input silence so the kernel never sees the device go quiet. + last_write: Vec, /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. broken: bool, } @@ -512,6 +516,7 @@ impl DualSenseManager { pads: (0..MAX_PADS).map(|_| None).collect(), state: vec![DsState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], + last_write: vec![Instant::now(); MAX_PADS], broken: false, } } @@ -604,6 +609,24 @@ impl DualSenseManager { if let Some(pad) = self.pads[idx].as_mut() { let _ = pad.write_state(&st); } + // Reset the heartbeat timer on every write (real input or heartbeat), so an actively-used + // pad emits no extra reports — the heartbeat only fills genuine input-silence gaps. + self.last_write[idx] = Instant::now(); + } + + /// Re-emit each live pad's CURRENT report if it's been silent for `max_gap`. A real DualSense + /// streams report `0x01` continuously (~250 Hz); the kernel `hid-playstation` driver / Proton / + /// SDL treat a multi-second silence (a held-steady stick produces no wire events) as an + /// unplugged controller — the "controller disconnected every few seconds" symptom. Re-sending + /// the current state is idempotent (a stale-but-correct frame, never a phantom input); + /// `write_state` bumps the report's seq + timestamp, so each is a fresh, well-formed report. + pub fn heartbeat(&mut self, max_gap: Duration) { + let now = Instant::now(); + for i in 0..self.pads.len() { + if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap { + self.write(i); + } + } } fn ensure(&mut self, idx: usize) { @@ -619,6 +642,7 @@ impl DualSenseManager { self.pads[idx] = Some(p); self.state[idx] = DsState::neutral(); self.last_rumble[idx] = (0, 0); + self.last_write[idx] = Instant::now(); } Err(e) => { tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled"); diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 0ca9445..f83285c 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -1198,6 +1198,19 @@ impl PadBackend { PadBackend::DualSense(m) => m.pump(rumble, hidout), } } + + /// Keep a virtual DualSense alive during input silence: re-emit its current HID report if it's + /// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as + /// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev + /// holds last-known state with no periodic-report requirement). Called every input-thread tick; + /// the per-pad gap timer (not the tick rate) governs the actual emit cadence. + fn heartbeat(&mut self) { + match self { + PadBackend::Xbox360(_) => {} + #[cfg(target_os = "linux")] + PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), + } + } } /// The per-session input thread: route pointer/keyboard events to the host-lifetime injector @@ -1293,6 +1306,10 @@ fn input_thread( let _ = conn.send_datagram(h.encode().into()); }, ); + // Keep the virtual DualSense from going silent during steady input (no-op for X-Box): a + // held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop + // it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick. + pads.heartbeat(); if last_refresh.elapsed() >= std::time::Duration::from_millis(500) { last_refresh = std::time::Instant::now(); for (i, &(low, high)) in rumble_state.iter().enumerate() {