feat(host/windows): ViGEm rumble back-channel + Windows clippy clean
android / android (push) Failing after 21s
ci / web (push) Failing after 10s
ci / docs-site (push) Failing after 1s
ci / bench (push) Failing after 0s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 1s
flatpak / build-publish (push) Failing after 0s
apple / swift (push) Successful in 53s
ci / rust (push) Failing after 2m35s

Wire the host→client rumble path on Windows, the analogue of the Linux
uinput EV_FF read loop: a game's force-feedback on the virtual Xbox 360
pad is delivered by ViGEm's notification API (`request_notification` →
`spawn_thread`, gated by the crate's `unstable_xtarget_notification`
feature). A per-pad background thread stores the latest motor levels;
`pump_rumble` relays changes to the client on the universal 0xCA plane
(motors scaled 0..255 → 0..65535). Dropping the target aborts the
notification, so the thread exits with the session. Live verification
still needs a physical pad.

Also fix the Windows backends' clippy debt — these modules are cfg-
excluded from Linux CI, so `clippy -D warnings` never saw them, and the
VM's rustc 1.96 clippy is stricter on shared code than the CI image:
- dxgi: manual checked division → checked_div().map_or
- sendinput: `x = x | y` → `x |= y`
- sudovda: `.then(|| ptr)` → `.then_some(ptr)`
- m3 pick_compositor: drop the needless early return (match form)
- m3 resolve_compositor: Windows arm is a tail expr, not `return`

All Windows backends now build + clippy clean (default and --features
nvenc); Linux unaffected (fmt/clippy/check green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 07:43:40 +00:00
parent 7d5dbd47b7
commit 9c61b03101
7 changed files with 105 additions and 34 deletions
@@ -4,16 +4,33 @@
//! triggers 0..255), so the mapping is ~1:1.
//!
//! Needs the ViGEmBus driver installed (like SudoVDA for the display); absent → gamepad is disabled
//! and the session continues without it. Rumble back-channel: TODO (ViGEm notification API).
//! and the session continues without it. Rumble flows back the *other* way: a game on the host writes
//! force-feedback to the virtual pad, ViGEm's notification API delivers it on a background thread,
//! and [`GamepadManager::pump_rumble`] relays level changes to the client (the universal 0xCA plane),
//! mirroring the Linux `EV_FF` read path.
use crate::gamestream::gamepad::GamepadEvent;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use vigem_client::{Client, TargetId, XButtons, XGamepad, Xbox360Wired};
/// A plugged virtual pad plus its rumble back-channel. The notification thread stores the latest
/// motor levels into `rumble` (packed `large << 8 | small`, both 0..255); [`GamepadManager::pump_rumble`]
/// reads it and emits level changes. Dropping `target` aborts the outstanding notification request,
/// so the thread's `poll` returns an error and it exits on its own — we detach it (per ViGEm's docs,
/// dropping the `JoinHandle` does not stop the thread, but the target-drop abort does).
struct PadEntry {
target: Xbox360Wired<Arc<Client>>,
rumble: Arc<AtomicU32>,
last_emitted: u32,
_notif_thread: Option<JoinHandle<()>>,
}
pub struct GamepadManager {
client: Option<Arc<Client>>,
pads: HashMap<u8, Xbox360Wired<Arc<Client>>>,
pads: HashMap<u8, PadEntry>,
}
impl GamepadManager {
@@ -37,19 +54,58 @@ impl GamepadManager {
}
}
/// Lazily plug pad `index` on its first event, arming the rumble notification thread. Returns
/// `None` if ViGEmBus is unavailable or the pad failed to plug.
fn ensure_pad(&mut self, index: u8) -> Option<&mut PadEntry> {
if !self.pads.contains_key(&index) {
let client = self.client.clone()?;
let mut target = Xbox360Wired::new(client, TargetId::XBOX360_WIRED);
if let Err(e) = target.plugin() {
tracing::warn!(error = format!("{e:?}"), "ViGEm pad plugin failed");
return None;
}
let _ = target.wait_ready();
// Arm the force-feedback back-channel: a background thread writes each notification's
// motor levels into the shared atomic; the input thread drains changes via pump_rumble.
let rumble = Arc::new(AtomicU32::new(0));
let notif_thread = match target.request_notification() {
Ok(req) => {
let sink = rumble.clone();
Some(req.spawn_thread(move |_req, n| {
sink.store(
((n.large_motor as u32) << 8) | n.small_motor as u32,
Ordering::Relaxed,
);
}))
}
Err(e) => {
tracing::warn!(
error = format!("{e:?}"),
"ViGEm rumble notification unavailable — pad runs without force feedback"
);
None
}
};
self.pads.insert(
index,
PadEntry {
target,
rumble,
last_emitted: 0,
_notif_thread: notif_thread,
},
);
}
self.pads.get_mut(&index)
}
pub fn handle(&mut self, ev: &GamepadEvent) {
let Some(client) = self.client.clone() else {
return;
};
let GamepadEvent::State(f) = ev else {
return; // Arrival metadata — the pad is created lazily on the first State
};
let target = self.pads.entry(f.index.max(0) as u8).or_insert_with(|| {
let mut t = Xbox360Wired::new(client, TargetId::XBOX360_WIRED);
let _ = t.plugin();
let _ = t.wait_ready();
t
});
let Some(entry) = self.ensure_pad(f.index.max(0) as u8) else {
return;
};
let gp = XGamepad {
buttons: XButtons {
raw: (f.buttons & 0xffff) as u16,
@@ -61,10 +117,22 @@ impl GamepadManager {
thumb_rx: f.rs_x,
thumb_ry: f.rs_y,
};
let _ = target.update(&gp);
let _ = entry.target.update(&gp);
}
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {
// TODO: wire the ViGEm rumble notification back-channel (Xbox360Wired::request_notification).
/// Relay any changed rumble level to the client. The notification thread keeps `rumble` current;
/// we emit only on change (the input thread re-sends the steady state every 500 ms to heal drops).
/// ViGEm motors are 0..255; the wire carries 0..65535, so scale by 257 (255 → 65535). `large`
/// (low-frequency) maps to the universal datagram's `low`, `small` (high-frequency) to `high`.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for (idx, entry) in self.pads.iter_mut() {
let packed = entry.rumble.load(Ordering::Relaxed);
if packed != entry.last_emitted {
entry.last_emitted = packed;
let large = ((packed >> 8) & 0xff) as u16;
let small = (packed & 0xff) as u16;
send(*idx as u16, large * 257, small * 257);
}
}
}
}
@@ -214,10 +214,10 @@ impl InputInjector for SendInputInjector {
let scan = (sc_ex & 0xff) as u16;
let mut flags = KEYEVENTF_SCANCODE;
if extended {
flags = flags | KEYEVENTF_EXTENDEDKEY;
flags |= KEYEVENTF_EXTENDEDKEY;
}
if !down {
flags = flags | KEYEVENTF_KEYUP;
flags |= KEYEVENTF_KEYUP;
}
let ki = KEYBDINPUT {
wVk: VIRTUAL_KEY(0),