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
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1520,12 +1520,10 @@ fn pick_compositor(
|
||||
available: &[crate::vdisplay::Compositor],
|
||||
detected: Option<crate::vdisplay::Compositor>,
|
||||
) -> Option<crate::vdisplay::Compositor> {
|
||||
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<crate::vdisplay::Composito
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = pref;
|
||||
return Ok(Compositor::Kwin);
|
||||
Ok(Compositor::Kwin)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
|
||||
@@ -78,8 +78,8 @@ struct RemoveParams {
|
||||
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty.
|
||||
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user