From 296b976b8fc8b8b1b39d9b3363b4b93a92b8e2ca Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 22:11:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(windows-client):=20SDL3=20gamepads=20+=20d?= =?UTF-8?q?ocs=20=E2=80=94=20full=20stage-1=20parity,=20MSVC-green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SDL3 gamepad service (near-verbatim port of the GTK client's — SDL3 is cross-platform) and wires it into the winit app: per-session capture (buttons/axes, DualSense touchpad + motion 0xCC), feedback (rumble, lightbar, raw DualSense effects), single-pad-forwarded model with auto pad-type from the physical controller. Built from source on Windows (no system SDL3). - gamepad.rs: GamepadService (app-lifetime SDL thread) attach/detach on session connect/end; auto_pref resolves "Automatic" to the attached pad's type. - app.rs: hold the service, attach on Connected, detach on Ended/Failed/close. Also simplify the keydown path (drop the identical if/else arms). - main.rs: start the service for the windowed path, resolve GamepadPref from settings + the physical pad. Build gotcha documented + fixed in the dev loop: SDL3's build-from-source MSVC precompiled-header chokes on the `ü` in the dev box's username embedded in the cargo registry path (MSB8084/C4828) — CARGO_HOME must be an ASCII path (C:\Users\Public\.cargo). Unrelated to our code. Docs: CLAUDE.md M4 + docs/windows-client-bootstrap.md status banner (winit-not-Reactor rationale, CARGO_HOME gotcha, what's pending) + docs-site clients.md "Windows desktop client (in development)". Crate is build + clippy + fmt + test green on x86_64-pc-windows-msvc. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 15 + Cargo.lock | 80 +++ crates/punktfunk-client-windows/Cargo.toml | 4 + crates/punktfunk-client-windows/src/app.rs | 32 +- .../punktfunk-client-windows/src/gamepad.rs | 538 ++++++++++++++++++ crates/punktfunk-client-windows/src/main.rs | 20 +- docs-site/content/docs/clients.md | 17 + docs/windows-client-bootstrap.md | 23 + 8 files changed, 711 insertions(+), 18 deletions(-) create mode 100644 crates/punktfunk-client-windows/src/gamepad.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8f8cd4c..b931bdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,21 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc Intel/AMD client box to live-verify the hw path. Next: the stage-2 raw-Wayland presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) — **wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit). + **Windows stage 1 done 2026-06-15** (`crates/punktfunk-client-windows`, binary + `punktfunk-client`): pure-Rust **winit + Direct3D11 flip-model swapchain** present (WARP + fallback for the GPU-less dev box; runtime-compiled fullscreen-triangle shaders, Contain-fit + letterbox), **FFmpeg software HEVC decode** (D3D11VA hw decode is the follow-up), **WASAPI** + shared-mode render + mic capture, keyboard (physical-`KeyCode`→VK) + absolute mouse + wheel + capture (Moonlight-style click-to-capture, Ctrl+Alt+Shift+Q release), **SDL3** gamepads + (rumble/lightbar/DualSense, built from source), `mdns-sd` discovery, shared client identity + + TOFU + SPAKE2 PIN pairing (`--connect`/`--discover`/`--pair`/`--headless`). Builds + clippy + + fmt + tests green on `x86_64-pc-windows-msvc` (built on the dev VM). **UI = winit + raw + D3D11, NOT WinUI3/Reactor** — a research pass confirmed windows-rs Reactor ships no + `SwapChainPanel`/`SetSwapChain` escape hatch, so it can't host the presenter (the bootstrap + doc's sanctioned fallback). Gotcha: `CARGO_HOME` must be an ASCII path — the `ü` in the dev + box's username breaks SDL3's MSVC precompiled-header build. Next: live host validation + (no GPU on the dev box → glass-to-glass defers to the RTX box), D3D11VA hw decode + 10-bit/HDR + present, a native host-list/settings GUI, and RAWINPUT relative-mouse pointer-lock. 2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms at high res). diff --git a/Cargo.lock b/Cargo.lock index 67cfc84..ad3122c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3123,6 +3123,7 @@ dependencies = [ "opus", "punktfunk-core", "raw-window-handle", + "sdl3", "serde", "serde_json", "tracing", @@ -3521,6 +3522,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpkg-config" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a2d2f3481209a6b42eec2fbb49063fb4e8d35b57023401495d4fe0f85c817f0" + [[package]] name = "rsa" version = "0.9.10" @@ -3770,16 +3777,89 @@ checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2" dependencies = [ "bitflags 2.13.0", "libc", + "sdl3-image-sys", + "sdl3-mixer-sys", "sdl3-sys", + "sdl3-ttf-sys", ] +[[package]] +name = "sdl3-image-src" +version = "3.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe273101c7dab94551183212eee9adef1a7bf274d407f0b7bfe72482960ab25c" + +[[package]] +name = "sdl3-image-sys" +version = "0.6.4+SDL-image-3.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a445f781b39a1c1bc751f5f4612191e0402006e35ad5d02d9193281afad1cf4" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-image-src", + "sdl3-sys", + "vcpkg", +] + +[[package]] +name = "sdl3-mixer-src" +version = "3.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd815ae87084588c7dbd027c1667b0a5e21a6b5ae7b22ecb230bc21c7063eca" + +[[package]] +name = "sdl3-mixer-sys" +version = "0.6.3+SDL-mixer-3.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5a157588924cf886bdc3a7c9d0e478cdad35d315740f26af00609a61e7c327" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-mixer-src", + "sdl3-sys", + "vcpkg", +] + +[[package]] +name = "sdl3-src" +version = "3.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4dcad85f1657d3424642ca2ed8f9f185212975baefda8972ac606494755a62" + [[package]] name = "sdl3-sys" version = "0.6.6+SDL-3.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7" dependencies = [ + "cc", + "cmake", "pkg-config", + "rpkg-config", + "sdl3-src", + "vcpkg", +] + +[[package]] +name = "sdl3-ttf-src" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8deaa09c46d6aa8e8a81a601eb4685b2a57f2ce8a4ea3c59e8b623b526d1125" + +[[package]] +name = "sdl3-ttf-sys" +version = "0.6.1+SDL-ttf-3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137096072109d6c834d4cb30b8a617ded4f150c7766757eddc834108bbcefd2" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-sys", + "sdl3-ttf-src", "vcpkg", ] diff --git a/crates/punktfunk-client-windows/Cargo.toml b/crates/punktfunk-client-windows/Cargo.toml index c6e196a..3824074 100644 --- a/crates/punktfunk-client-windows/Cargo.toml +++ b/crates/punktfunk-client-windows/Cargo.toml @@ -51,6 +51,10 @@ opus = "0.3" # Audio render + mic capture (the WASAPI analogue of the Linux client's PipeWire backend). wasapi = "0.23" +# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform; +# built from source via the bundled CMake on Windows (no system SDL3). +sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] } + mdns-sd = "0.20" async-channel = "2" serde = { version = "1", features = ["derive"] } diff --git a/crates/punktfunk-client-windows/src/app.rs b/crates/punktfunk-client-windows/src/app.rs index b2242eb..af36103 100644 --- a/crates/punktfunk-client-windows/src/app.rs +++ b/crates/punktfunk-client-windows/src/app.rs @@ -23,7 +23,7 @@ use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use std::collections::HashSet; use std::sync::atomic::Ordering; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; use windows::Win32::Foundation::HWND; use winit::application::ApplicationHandler; use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; @@ -43,7 +43,8 @@ pub struct ConnectInfo { pub struct WinApp { handle: SessionHandle, info: ConnectInfo, - inhibit_shortcuts: bool, + /// App-lifetime SDL gamepad service: per-session capture + rumble/HID feedback. + gamepad: crate::gamepad::GamepadService, window: Option>, renderer: Option, @@ -60,11 +61,15 @@ pub struct WinApp { } impl WinApp { - pub fn new(handle: SessionHandle, info: ConnectInfo, inhibit_shortcuts: bool) -> WinApp { + pub fn new( + handle: SessionHandle, + info: ConnectInfo, + gamepad: crate::gamepad::GamepadService, + ) -> WinApp { WinApp { handle, info, - inhibit_shortcuts, + gamepad, window: None, renderer: None, swap: None, @@ -191,6 +196,7 @@ impl WinApp { self.info.name, mode.width, mode.height, mode.refresh_hz )); } + self.gamepad.attach(connector.clone()); self.connector = Some(connector); tracing::info!(?mode, "connected — streaming"); } @@ -210,11 +216,13 @@ impl WinApp { "host fingerprint changed or pairing required — re-pair with --pair PIN" ); } + self.gamepad.detach(); event_loop.exit(); return false; } SessionEvent::Ended(err) => { tracing::info!(reason = err.as_deref().unwrap_or("done"), "session ended"); + self.gamepad.detach(); event_loop.exit(); return false; } @@ -324,6 +332,7 @@ impl ApplicationHandler for WinApp { match event { WindowEvent::CloseRequested => { self.handle.stop.store(true, Ordering::SeqCst); + self.gamepad.detach(); event_loop.exit(); } WindowEvent::Resized(size) => { @@ -364,12 +373,10 @@ impl ApplicationHandler for WinApp { return; }; if event.state.is_pressed() { - if self.held_keys.insert(vk) { - self.send(InputKind::KeyDown, vk as u32, 0, 0, 0); - } else { - // Auto-repeat: re-send KeyDown (the host tolerates repeats). - self.send(InputKind::KeyDown, vk as u32, 0, 0, 0); - } + // Track held state for flush-on-release; re-send on auto-repeat too (the + // host treats KeyDown as a state set, so repeats are harmless). + self.held_keys.insert(vk); + self.send(InputKind::KeyDown, vk as u32, 0, 0, 0); } else if self.held_keys.remove(&vk) { self.send(InputKind::KeyUp, vk as u32, 0, 0, 0); } @@ -429,10 +436,5 @@ impl ApplicationHandler for WinApp { // No frame this turn — yield briefly instead of spinning a core flat-out. std::thread::sleep(Duration::from_millis(1)); } - let _ = Instant::now(); - // Auto-engage capture once the first frame is on screen and the window has focus. - if self.have_frame && !self.captured && self.inhibit_shortcuts { - // (inhibit_shortcuts gates nothing yet on Windows; capture auto-engages on click.) - } } } diff --git a/crates/punktfunk-client-windows/src/gamepad.rs b/crates/punktfunk-client-windows/src/gamepad.rs new file mode 100644 index 0000000..6f282da --- /dev/null +++ b/crates/punktfunk-client-windows/src/gamepad.rs @@ -0,0 +1,538 @@ +//! App-lifetime gamepad service over SDL3 (mirrors the Swift/GTK clients' `GamepadManager` + +//! capture/feedback). Ported near-verbatim from the GTK Linux client — SDL3 is cross-platform, +//! so the only Windows change is the build (`sdl3` is compiled from source via the bundled +//! CMake, since there is no system SDL3). +//! +//! One worker thread owns SDL for the process lifetime: it tracks connected pads, selects the +//! ONE controller forwarded as pad 0 (user pin, else the most recently connected), and — while +//! a session is attached — forwards buttons/axes, DualSense touchpad contacts and motion +//! samples (0xCC), and renders feedback: rumble on every pad, lightbar via SDL, and on a real +//! DualSense the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). +//! Held state is zeroed on the wire when the active pad switches or the session detaches, so +//! nothing sticks down. +//! +//! This thread is also the single consumer of the rumble and HID-output pull planes. + +use punktfunk_core::client::NativeClient; +use punktfunk_core::config::GamepadPref; +use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; +use punktfunk_core::quic::{HidOutput, RichInput}; +use std::collections::HashMap; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Motion scale constants, shared convention with the other clients (`GamepadWire`): derived +/// from hid-playstation's math over the host's fixed calibration blob. SDL hands us gyro in +/// rad/s and accel in m/s²; the DualSense report wants raw LSBs. +const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI; +const ACCEL_LSB_PER_G: f32 = 10_000.0; +const G: f32 = 9.80665; + +#[derive(Clone, Debug)] +pub struct PadInfo { + // `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only + // reads `is_dualsense` (via `auto_pref`), so they're unused in reachable code for now. + #[allow(dead_code)] + pub id: u32, + #[allow(dead_code)] + pub name: String, + pub is_dualsense: bool, +} + +enum Ctl { + Attach(Arc), + Detach, + Pin(Option), +} + +#[derive(Clone)] +pub struct GamepadService { + pads: Arc>>, + active: Arc>>, + pinned: Arc>>, + ctl: Sender, +} + +impl GamepadService { + pub fn start() -> GamepadService { + let pads = Arc::new(Mutex::new(Vec::new())); + let active = Arc::new(Mutex::new(None)); + let pinned = Arc::new(Mutex::new(None)); + let (ctl, ctl_rx) = std::sync::mpsc::channel(); + let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone()); + if let Err(e) = std::thread::Builder::new() + .name("punktfunk-gamepad".into()) + .spawn(move || { + if let Err(e) = run(&p, &a, &pin, &ctl_rx) { + tracing::warn!(error = %e, "gamepad service ended — pads disabled"); + } + }) + { + tracing::warn!(error = %e, "gamepad service failed to start"); + } + GamepadService { + pads, + active, + pinned, + ctl, + } + } + + #[allow(dead_code)] // consumed by the settings GUI (follow-up) + pub fn pads(&self) -> Vec { + self.pads.lock().unwrap().clone() + } + + pub fn active(&self) -> Option { + self.active.lock().unwrap().clone() + } + + #[allow(dead_code)] // consumed by the settings GUI (follow-up) + pub fn pinned(&self) -> Option { + *self.pinned.lock().unwrap() + } + + #[allow(dead_code)] // consumed by the settings GUI (follow-up) + pub fn set_pinned(&self, id: Option) { + let _ = self.ctl.send(Ctl::Pin(id)); + } + + pub fn attach(&self, connector: Arc) { + let _ = self.ctl.send(Ctl::Attach(connector)); + } + + pub fn detach(&self) { + let _ = self.ctl.send(Ctl::Detach); + } + + /// What "Automatic" resolves to right now — the virtual pad matching the physical one + /// (Swift parity); no pad connected leaves the host's own default. + pub fn auto_pref(&self) -> GamepadPref { + match self.active() { + Some(p) if p.is_dualsense => GamepadPref::DualSense, + Some(_) => GamepadPref::Xbox360, + None => GamepadPref::Auto, + } + } +} + +fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) { + let _ = connector.send_input(&InputEvent { + kind, + _pad: [0; 3], + code, + x, + y: 0, + flags: 0, // pad index 0 — single-pad model + }); +} + +fn button_bit(b: sdl3::gamepad::Button) -> Option { + use sdl3::gamepad::Button; + Some(match b { + Button::South => wire::BTN_A, + Button::East => wire::BTN_B, + Button::West => wire::BTN_X, + Button::North => wire::BTN_Y, + Button::Back => wire::BTN_BACK, + Button::Start => wire::BTN_START, + Button::Guide => wire::BTN_GUIDE, + Button::LeftStick => wire::BTN_LS_CLICK, + Button::RightStick => wire::BTN_RS_CLICK, + Button::LeftShoulder => wire::BTN_LB, + Button::RightShoulder => wire::BTN_RB, + Button::DPadUp => wire::BTN_DPAD_UP, + Button::DPadDown => wire::BTN_DPAD_DOWN, + Button::DPadLeft => wire::BTN_DPAD_LEFT, + Button::DPadRight => wire::BTN_DPAD_RIGHT, + Button::Touchpad => wire::BTN_TOUCHPAD, + _ => return None, + }) +} + +/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput +/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255. +fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { + use sdl3::gamepad::Axis; + match axis { + Axis::LeftX => (wire::AXIS_LS_X, v as i32), + Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)), + Axis::RightX => (wire::AXIS_RS_X, v as i32), + Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)), + Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7), + Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7), + } +} + +/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the host +/// parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. Enable bits +/// select only the fields each update touches, so rumble (driven separately through SDL) and +/// untouched fields keep their state. +#[derive(Default)] +struct Ds5Feedback; + +impl Ds5Feedback { + const RIGHT_TRIGGER: usize = 10; + const LEFT_TRIGGER: usize = 21; + const PAD_LIGHTS: usize = 43; + const LED_RGB: usize = 44; + + fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] { + let mut p = [0u8; 47]; + let (flag, off) = if which == 1 { + (0x04, Self::RIGHT_TRIGGER) + } else { + (0x08, Self::LEFT_TRIGGER) + }; + p[0] = flag; + let n = effect.len().min(11); + p[off..off + n].copy_from_slice(&effect[..n]); + p + } + + fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] { + let mut p = [0u8; 47]; + p[1] = 0x04; // lightbar enable + p[Self::LED_RGB] = r; + p[Self::LED_RGB + 1] = g; + p[Self::LED_RGB + 2] = b; + p + } + + fn player_packet(bits: u8) -> [u8; 47] { + let mut p = [0u8; 47]; + p[1] = 0x10; // player-LED enable + p[Self::PAD_LIGHTS] = bits & 0x1F; + p + } +} + +struct Worker { + subsystem: sdl3::GamepadSubsystem, + opened: HashMap, + /// Connection order; the most recently connected is the auto selection. + order: Vec, + pinned: Option, + attached: Option>, + /// Wire state of the active pad — zeroed on the wire at switch/detach. + last_axis: [i32; 6], + held_buttons: Vec, + last_accel: [i16; 3], +} + +impl Worker { + fn active_id(&self) -> Option { + self.pinned + .filter(|id| self.opened.contains_key(id)) + .or_else(|| self.order.last().copied()) + } + + fn pad_info(&self, id: u32) -> Option { + let pad = self.opened.get(&id)?; + Some(PadInfo { + id, + name: pad.name().unwrap_or_else(|| "Controller".into()), + is_dualsense: matches!( + self.subsystem + .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), + sdl3::gamepad::GamepadType::PS5 + ), + }) + } + + /// Zero everything the host believes is held — on pad switch and detach. + fn flush_held(&mut self) { + if let Some(c) = &self.attached { + for b in self.held_buttons.drain(..) { + send(c, InputKind::GamepadButton, b, 0); + } + for (id, v) in self.last_axis.iter_mut().enumerate() { + if *v != 0 && *v != i32::MIN { + send(c, InputKind::GamepadAxis, id as u32, 0); + } + *v = i32::MIN; + } + } else { + self.held_buttons.clear(); + self.last_axis = [i32::MIN; 6]; + } + } + + /// Sensors stream only while a session wants them (they cost USB/BT bandwidth). + fn set_sensors(&mut self, enabled: bool) { + let Some(id) = self.active_id() else { return }; + if let Some(pad) = self.opened.get_mut(&id) { + use sdl3::sensor::SensorType; + for s in [SensorType::Gyroscope, SensorType::Accelerometer] { + if unsafe { pad.has_sensor(s) } { + let _ = pad.sensor_set_enabled(s, enabled); + } + } + } + } +} + +#[allow(clippy::too_many_lines)] +fn run( + pads_out: &Mutex>, + active_out: &Mutex>, + pinned_out: &Mutex>, + ctl: &Receiver, +) -> Result<(), String> { + // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own + // thread. + sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); + sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); + let sdl = sdl3::init().map_err(|e| e.to_string())?; + let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; + let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; + + let mut w = Worker { + subsystem, + opened: HashMap::new(), + order: Vec::new(), + pinned: None, + attached: None, + last_axis: [i32::MIN; 6], + held_buttons: Vec::new(), + last_accel: [0; 3], + }; + + let publish = |w: &Worker| { + let mut list: Vec = w.order.iter().filter_map(|&id| w.pad_info(id)).collect(); + list.reverse(); // most recent first — the Settings list order + *pads_out.lock().unwrap() = list; + *active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id)); + *pinned_out.lock().unwrap() = w.pinned; + }; + + loop { + // Control plane from the UI thread. + loop { + match ctl.try_recv() { + Ok(Ctl::Attach(c)) => { + w.attached = Some(c); + w.last_axis = [i32::MIN; 6]; + w.set_sensors(true); + } + Ok(Ctl::Detach) => { + w.flush_held(); + w.set_sensors(false); + w.attached = None; + } + Ok(Ctl::Pin(id)) => { + let before = w.active_id(); + w.pinned = id; + if w.active_id() != before { + w.flush_held(); + if w.attached.is_some() { + w.set_sensors(true); + } + } + publish(&w); + } + Err(std::sync::mpsc::TryRecvError::Empty) => break, + Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone + } + } + + while let Some(event) = pump.poll_event() { + use sdl3::event::Event; + let active = w.active_id(); + match event { + Event::ControllerDeviceAdded { which, .. } => { + if !w.opened.contains_key(&which) { + match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) { + Ok(pad) => { + tracing::info!( + name = pad.name().unwrap_or_default(), + "gamepad attached" + ); + w.opened.insert(which, pad); + w.order.push(which); + if w.attached.is_some() && w.active_id() == Some(which) { + w.set_sensors(true); + } + publish(&w); + } + Err(e) => tracing::warn!(error = %e, "gamepad open failed"), + } + } + } + Event::ControllerDeviceRemoved { which, .. } => { + if w.opened.remove(&which).is_some() { + w.order.retain(|&id| id != which); + if active == Some(which) { + w.flush_held(); + } + tracing::info!("gamepad detached"); + publish(&w); + } + } + Event::ControllerButtonDown { which, button, .. } + if active == Some(which) && w.attached.is_some() => + { + if let Some(bit) = button_bit(button) { + w.held_buttons.push(bit); + send( + w.attached.as_ref().unwrap(), + InputKind::GamepadButton, + bit, + 1, + ); + } + } + Event::ControllerButtonUp { which, button, .. } + if active == Some(which) && w.attached.is_some() => + { + if let Some(bit) = button_bit(button) { + w.held_buttons.retain(|&b| b != bit); + send( + w.attached.as_ref().unwrap(), + InputKind::GamepadButton, + bit, + 0, + ); + } + } + Event::ControllerAxisMotion { + which, axis, value, .. + } if active == Some(which) && w.attached.is_some() => { + let (id, v) = axis_value(axis, value); + if w.last_axis[id as usize] != v { + w.last_axis[id as usize] = v; + send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v); + } + } + // DualSense touchpad → the rich-input plane, normalized 0..=65535. + Event::ControllerTouchpadDown { + which, + finger, + x, + y, + .. + } + | Event::ControllerTouchpadMotion { + which, + finger, + x, + y, + .. + } if active == Some(which) && w.attached.is_some() => { + let _ = w + .attached + .as_ref() + .unwrap() + .send_rich_input(RichInput::Touchpad { + pad: 0, + finger: finger as u8, + active: true, + x: (x.clamp(0.0, 1.0) * 65535.0) as u16, + y: (y.clamp(0.0, 1.0) * 65535.0) as u16, + }); + } + Event::ControllerTouchpadUp { + which, + finger, + x, + y, + .. + } if active == Some(which) && w.attached.is_some() => { + let _ = w + .attached + .as_ref() + .unwrap() + .send_rich_input(RichInput::Touchpad { + pad: 0, + finger: finger as u8, + active: false, + x: (x.clamp(0.0, 1.0) * 65535.0) as u16, + y: (y.clamp(0.0, 1.0) * 65535.0) as u16, + }); + } + // Motion: accel events update the cache; each gyro event ships a sample (the + // DualSense reports both at ~250 Hz). Scale convention shared with the other + // clients — sign/scale derived, not yet live-verified. + Event::ControllerSensorUpdated { + which, + sensor, + data, + .. + } if active == Some(which) && w.attached.is_some() => { + use sdl3::sensor::SensorType; + match sensor { + SensorType::Accelerometer => { + for (i, v) in data.iter().enumerate() { + w.last_accel[i] = + (v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16; + } + } + SensorType::Gyroscope => { + let mut gyro = [0i16; 3]; + for (i, v) in data.iter().enumerate() { + gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; + } + let _ = + w.attached + .as_ref() + .unwrap() + .send_rich_input(RichInput::Motion { + pad: 0, + gyro, + accel: w.last_accel, + }); + } + _ => {} + } + } + _ => {} + } + } + + // Feedback planes (this thread is their single consumer). The host re-sends rumble state + // periodically, so a generous duration with refresh-on-update is safe — a dropped stop + // heals within ~500 ms. + if let Some(connector) = w.attached.clone() { + while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { + if pad == 0 { + if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) { + let _ = p.set_rumble(low, high, 5_000); + } + } + } + while let Ok(hid) = connector.next_hidout(Duration::ZERO) { + let Some(id) = w.active_id() else { continue }; + let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense); + let Some(pad) = w.opened.get_mut(&id) else { + continue; + }; + match hid { + HidOutput::Led { pad: 0, r, g, b } if is_ds => { + let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); + } + HidOutput::Led { pad: 0, r, g, b } => { + let _ = pad.set_led(r, g, b); + } + HidOutput::PlayerLeds { pad: 0, bits } if is_ds => { + let _ = pad.send_effect(&Ds5Feedback::player_packet(bits)); + } + HidOutput::Trigger { + pad: 0, + which, + ref effect, + } if is_ds => { + let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect)); + } + _ => {} + } + } + } + + std::thread::sleep(Duration::from_millis(if w.attached.is_some() { + 2 + } else { + 30 + })); + } +} diff --git a/crates/punktfunk-client-windows/src/main.rs b/crates/punktfunk-client-windows/src/main.rs index 61a7587..497694b 100644 --- a/crates/punktfunk-client-windows/src/main.rs +++ b/crates/punktfunk-client-windows/src/main.rs @@ -21,6 +21,8 @@ mod audio; #[cfg(windows)] mod discovery; #[cfg(windows)] +mod gamepad; +#[cfg(windows)] mod keymap; #[cfg(windows)] mod present; @@ -153,20 +155,31 @@ fn main() { } } + let headless = flag("--headless"); + // The app-lifetime gamepad service runs only for the windowed client; it also resolves the + // "Automatic" pad type to whatever physical controller is attached (other-client parity). + let gamepad_service = (!headless).then(gamepad::GamepadService::start); + let gamepad_pref = match GamepadPref::from_name(&settings.gamepad) { + Some(GamepadPref::Auto) | None => gamepad_service + .as_ref() + .map_or(GamepadPref::Auto, |s| s.auto_pref()), + Some(explicit) => explicit, + }; + tracing::info!(%host, port, ?mode, tofu = pin.is_none(), "connecting"); let handle = session::start(session::SessionParams { host: host.clone(), port, mode, compositor: CompositorPref::Auto, - gamepad: GamepadPref::Auto, + gamepad: gamepad_pref, bitrate_kbps, mic_enabled, pin, identity, }); - if flag("--headless") { + if headless { run_headless(handle); return; } @@ -177,7 +190,8 @@ fn main() { port, tofu: pin.is_none(), }; - if let Err(e) = app::WinApp::new(handle, info, true).run() { + let gamepad_service = gamepad_service.expect("started for the windowed path"); + if let Err(e) = app::WinApp::new(handle, info, gamepad_service).run() { tracing::error!(error = %e, "windowed app failed"); std::process::exit(1); } diff --git a/docs-site/content/docs/clients.md b/docs-site/content/docs/clients.md index 1cfcbff..79a94e5 100644 --- a/docs-site/content/docs/clients.md +++ b/docs-site/content/docs/clients.md @@ -54,6 +54,23 @@ connect straight away: punktfunk-client --connect :9777 # skip the picker, start a session immediately ``` +## Windows desktop client (in development) + +`punktfunk-client` for Windows (`crates/punktfunk-client-windows`) is the native graphical client +for Windows — pure Rust, the same `punktfunk/1` core as the Apple and Linux apps, with a winit + +Direct3D11 present surface, WASAPI audio, FFmpeg decode, SDL3 controllers, network discovery, and +PIN pairing. It builds on `x86_64-pc-windows-msvc` and runs the connect/decode/present/input path; +hardware (D3D11VA) decode, 10-bit/HDR present, and a native host-list/settings window are in +progress, so it is not yet packaged. For now it is driven from the command line: + +```sh +punktfunk-client --discover # list hosts on the network +punktfunk-client --connect :9777 # stream (trust-on-first-use) +punktfunk-client --connect :9777 --pair 1234 # pair with the PIN the host shows +``` + +Until it ships, **Moonlight** remains the recommended way to stream to Windows (see below). + ## Linux reference client (headless) `punktfunk-client-rs` (in the repo) is a command-line client for the native protocol, used for diff --git a/docs/windows-client-bootstrap.md b/docs/windows-client-bootstrap.md index 2aa050b..6192156 100644 --- a/docs/windows-client-bootstrap.md +++ b/docs/windows-client-bootstrap.md @@ -5,6 +5,29 @@ and live-validated on a real RTX 4090; the client is the remaining piece. This d starting point: the locked decisions, the reference code to port, the stack swaps, the dev loop, and the gotchas. Read it top to bottom, then start at **Phase 1** (de-risk Reactor first). +## Status — stage 1 landed (2026-06-15) + +The client is implemented in `crates/punktfunk-client-windows` (binary `punktfunk-client`) and is +**build + clippy + fmt + test green on `x86_64-pc-windows-msvc`** (built on the dev VM). Done: winit +window + **Direct3D11 flip-model swapchain** present (WARP on the GPU-less box; runtime-compiled +fullscreen-triangle shaders, Contain-fit letterbox), **FFmpeg software HEVC decode**, **WASAPI** render ++ mic capture, keyboard/mouse/wheel capture (physical-`KeyCode`→VK, click-to-capture), **SDL3** +gamepads, `mdns-sd` discovery, and the full trust surface (identity + TOFU + SPAKE2 PIN over +`--connect`/`--discover`/`--pair`/`--headless`). + +- **Reactor was evaluated and rejected** (a research pass + the points below): windows-rs Reactor + ships **no `SwapChainPanel` and no `ISwapChainPanelNative::SetSwapChain` escape hatch**, so it + cannot host a DXGI presenter. The client uses the doc's sanctioned fallback — **winit + a raw + D3D11 swapchain on the HWND** — which builds and runs against WARP on the GPU-less VM. A native + WinUI look would need the `SwapChainPanel` hatch to land upstream first. +- **Build gotcha (in addition to the ASCII *output* path below):** `CARGO_HOME` itself must be on an + **ASCII path** (e.g. `C:\Users\Public\.cargo`). SDL3's `build-from-source` compiles a precompiled + header whose `#include` embeds the registry source path; the `ü` in the dev box's username makes + MSVC's PCH/structured-output fail (`MSB8084` / `C4828`). Set `CARGO_HOME=C:\Users\Public\.cargo`. +- **Still pending:** live host validation (the dev box has no GPU → glass-to-glass numbers defer to + the RTX box), **D3D11VA hardware decode** + **10-bit/HDR present**, a native host-list/settings + GUI (CLI flags for now), and RAWINPUT relative-mouse pointer-lock. Phases 4–7 below are the map. + ## What we're building A native Windows client that connects to a punktfunk/1 host (`serve --native` / `m3-host`), decodes