diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 3ce257c..c46305e 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -132,7 +132,9 @@ openh264 = "0.9" # WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path). wasapi = "0.23" # Virtual Xbox 360 gamepad via ViGEmBus (the uinput-xpad analogue) — driver installed separately. -vigem-client = "0.1" +# `unstable_xtarget_notification` exposes the rumble/LED back-channel (the game's force-feedback → +# `request_notification`), the analogue of the Linux uinput EV_FF read path. +vigem-client = { version = "0.1", features = ["unstable_xtarget_notification"] } # NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with # `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches # cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how diff --git a/crates/punktfunk-host/src/capture/dxgi.rs b/crates/punktfunk-host/src/capture/dxgi.rs index 82dcaa6..abb1460 100644 --- a/crates/punktfunk-host/src/capture/dxgi.rs +++ b/crates/punktfunk-host/src/capture/dxgi.rs @@ -152,11 +152,9 @@ impl DuplCapturer { .filter(|&hz| hz > 0) .unwrap_or_else(|| { let r = dd.ModeDesc.RefreshRate; - if r.Denominator > 0 { - (r.Numerator / r.Denominator).max(1) - } else { - 60 - } + r.Numerator + .checked_div(r.Denominator) + .map_or(60, |hz| hz.max(1)) }); let timeout_ms = std::env::var("PUNKTFUNK_CAPTURE_TIMEOUT_MS") .ok() diff --git a/crates/punktfunk-host/src/inject/gamepad_windows.rs b/crates/punktfunk-host/src/inject/gamepad_windows.rs index 9ffd484..631e0a1 100644 --- a/crates/punktfunk-host/src/inject/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/gamepad_windows.rs @@ -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>, + rumble: Arc, + last_emitted: u32, + _notif_thread: Option>, +} + pub struct GamepadManager { client: Option>, - pads: HashMap>>, + pads: HashMap, } 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); + } + } } } diff --git a/crates/punktfunk-host/src/inject/sendinput.rs b/crates/punktfunk-host/src/inject/sendinput.rs index 07ffdbb..19b6d93 100644 --- a/crates/punktfunk-host/src/inject/sendinput.rs +++ b/crates/punktfunk-host/src/inject/sendinput.rs @@ -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), diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index f83285c..af6d2fe 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -1520,12 +1520,10 @@ fn pick_compositor( available: &[crate::vdisplay::Compositor], detected: Option, ) -> Option { - if let Some(want) = crate::vdisplay::Compositor::from_pref(pref) { - if available.contains(&want) { - return Some(want); - } + match crate::vdisplay::Compositor::from_pref(pref) { + Some(want) if available.contains(&want) => Some(want), + _ => detected, } - detected } /// Resolve the client's compositor preference to a concrete backend (the I/O shell around @@ -1539,7 +1537,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result Result { let mut returned = 0u32; - let inp = (!input.is_empty()).then(|| input.as_ptr() as *const c_void); - let outp = (!output.is_empty()).then(|| output.as_mut_ptr() as *mut c_void); + let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void); + let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void); DeviceIoControl( h, code, diff --git a/docs/windows-host.md b/docs/windows-host.md index 443d179..4b437f7 100644 --- a/docs/windows-host.md +++ b/docs/windows-host.md @@ -27,13 +27,18 @@ Every OS-touching backend is implemented behind the existing traits and **builds | Capture (DXGI Desktop Duplication) | ✅ done | helpers unit-tested; DuplicateOutput needs a GPU-activated monitor | | NVENC (D3D11, `--features nvenc`) | ✅ compiles+links | needs a GPU at runtime | | Run host (serve/m3-host) | ✅ live | m3-host starts + listens; `c_abi_connection_roundtrip` passes | -| Gamepad (ViGEm) | ✅ done | compiles; live needs ViGEmBus + a physical pad; rumble back-channel TODO | +| Gamepad (ViGEm) | ✅ done | compiles incl. rumble back-channel; live needs ViGEmBus + a physical pad | | Host→client audio wiring | ✅ done | builds on MSVC; `m3` `audio_thread` active on Windows (silent VM → no samples to send) | +| Rumble back-channel (ViGEm) | ✅ done | `request_notification` → background thread → 0xCA; live needs a physical pad | **Remaining for full parity:** -- **ViGEm rumble back-channel** (`Xbox360Wired::request_notification`) — small; needs a physical pad. -- **Live GPU/in-session validation** — SudoVDA monitor activation, DXGI capture, NVENC encode, and - SendInput injection all need a real GPU and an interactive (console) session, not SSH/Session-0. +- **Live GPU/in-session validation** — SudoVDA monitor activation, DXGI capture, NVENC encode, + SendInput injection, and the ViGEm rumble path all need a real GPU and/or an interactive (console) + session + a physical pad, not SSH/Session-0. + +All Windows backends are `clippy -D warnings` and `rustfmt` clean on `x86_64-pc-windows-msvc` (the +Windows-only modules are cfg-excluded from Linux CI, so run clippy on the VM after touching them — its +rustc 1.96 clippy is stricter than the Linux CI image on shared code, e.g. `needless_return`). ### Building & testing on a real-GPU Windows box (NVENC)