feat(windows-client): SDL3 gamepads + docs — full stage-1 parity, MSVC-green
apple / swift (push) Successful in 54s
audit / cargo-audit (push) Failing after 1m19s
android / android (push) Failing after 2m22s
ci / web (push) Successful in 41s
ci / docs-site (push) Successful in 33s
ci / bench (push) Successful in 1m56s
deb / build-publish (push) Successful in 3m28s
ci / rust (push) Successful in 7m23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
decky / build-publish (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m21s
docker / deploy-docs (push) Successful in 7s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m43s
apple / swift (push) Successful in 54s
audit / cargo-audit (push) Failing after 1m19s
android / android (push) Failing after 2m22s
ci / web (push) Successful in 41s
ci / docs-site (push) Successful in 33s
ci / bench (push) Successful in 1m56s
deb / build-publish (push) Successful in 3m28s
ci / rust (push) Successful in 7m23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
decky / build-publish (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m21s
docker / deploy-docs (push) Successful in 7s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m43s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
Generated
+80
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<Arc<Window>>,
|
||||
renderer: Option<Renderer>,
|
||||
@@ -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) {
|
||||
// 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 {
|
||||
// Auto-repeat: re-send KeyDown (the host tolerates repeats).
|
||||
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.)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<u32>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active: Arc<Mutex<Option<PadInfo>>>,
|
||||
pinned: Arc<Mutex<Option<u32>>>,
|
||||
ctl: Sender<Ctl>,
|
||||
}
|
||||
|
||||
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<PadInfo> {
|
||||
self.pads.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn active(&self) -> Option<PadInfo> {
|
||||
self.active.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // consumed by the settings GUI (follow-up)
|
||||
pub fn pinned(&self) -> Option<u32> {
|
||||
*self.pinned.lock().unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // consumed by the settings GUI (follow-up)
|
||||
pub fn set_pinned(&self, id: Option<u32>) {
|
||||
let _ = self.ctl.send(Ctl::Pin(id));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
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<u32> {
|
||||
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<u32, sdl3::gamepad::Gamepad>,
|
||||
/// Connection order; the most recently connected is the auto selection.
|
||||
order: Vec<u32>,
|
||||
pinned: Option<u32>,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
last_accel: [i16; 3],
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn active_id(&self) -> Option<u32> {
|
||||
self.pinned
|
||||
.filter(|id| self.opened.contains_key(id))
|
||||
.or_else(|| self.order.last().copied())
|
||||
}
|
||||
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
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<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
pinned_out: &Mutex<Option<u32>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
) -> 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<PadInfo> = 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
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,23 @@ connect straight away:
|
||||
punktfunk-client --connect <host>: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 <host>:9777 # stream (trust-on-first-use)
|
||||
punktfunk-client --connect <host>: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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user