feat(gamepad): controller discovery + client-negotiated pad type + rich DualSense end to end

The Apple client grows full gamepad support and punktfunk/1 learns to negotiate
the virtual pad type:

- Protocol: Hello carries a GamepadPref byte (offset 21, the same trailing-byte
  back-compat pattern as the compositor; echoed resolved in Welcome at 54).
  Host precedence: explicit client choice > PUNKTFUNK_GAMEPAD env > Xbox 360,
  DualSense (UHID) only where available. ABI: punktfunk_connect_ex2 +
  punktfunk_connection_gamepad (connect_ex delegates; ABI_VERSION stays 2 — the
  trailing byte IS the compat mechanism). punktfunk-client-rs gets --gamepad.

- Swift client: GamepadManager (app-lifetime discovery + selection — Settings
  lists every controller with capabilities/battery/"In use"; exactly ONE pad
  forwards as pad 0, auto = most recently connected, or pinned), GamepadCapture
  (snapshot-diff button/axis events, DualSense touchpad + ~250 Hz motion on the
  rich-input plane, held state released on switch/deactivate/stop),
  GamepadFeedback (rumble → CoreHaptics per-handle engines; lightbar →
  GCDeviceLight; player LEDs → playerIndex; adaptive-trigger blocks → the
  table-driven DualSenseTriggerEffect parser → GCDualSenseAdaptiveTrigger,
  exact for the 10-zone positional modes). The pad type auto-resolves from the
  physical controller at connect time, user-overridable in Settings.

- Host DualSense fixes surfaced by adversarial review against hid-playstation /
  SDL / Nielk1 ground truth: input-report sensor/touch offsets were off by one
  (the kernel read garbage motion + phantom touches), the L2/R2 trigger blocks
  were swapped (the report is right-trigger-first), feedback now gates on the
  report's valid-flags (a plain rumble write no longer blanks lightbar/
  triggers), and the touchpad rescale clamps to the advertised ABS_MT extents.

- Tests: Hello/Welcome trailing-byte back-compat, pick_gamepad precedence,
  byte-exact input-report layout, valid-flag gating, per-mode trigger-parser
  table (incl. packed 3-bit zones), wire conversions, and a scripted loopback
  feedback burst (PUNKTFUNK_TEST_FEEDBACK=1) asserted through the xcframework
  on the rumble + HID-output planes.

Validated: cargo test/clippy/fmt green on macOS + Linux (61 host tests), swift
build/test green, test-loopback.sh green, tvOS/iOS targets compile. DualSense
motion sign/scale is derived from the calibration blob, not yet live-verified
(constants isolated in GamepadWire).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:28:33 +02:00
parent d86896da16
commit 1d605fb781
24 changed files with 2321 additions and 142 deletions
+129 -12
View File
@@ -23,7 +23,7 @@
//! with GameStream pairing) and logs the SHA-256 fingerprint clients pin.
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, Role};
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_SOF};
use punktfunk_core::quic::{
@@ -387,6 +387,10 @@ async fn serve_session(
M3Source::Synthetic => None,
};
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
// needed; the actual pads are created lazily by the input thread).
let gamepad = resolve_gamepad(hello.gamepad);
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport).
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
let udp_port = probe.local_addr()?.port();
@@ -412,10 +416,12 @@ async fn serve_session(
M3Source::Synthetic => frames,
M3Source::Virtual => 0, // unbounded — client streams until we close
},
// Report the resolved backend back to the client (Auto for the synthetic source).
// Report the resolved backends back to the client (compositor: Auto for the
// synthetic source).
compositor: compositor
.map(|c| c.as_pref())
.unwrap_or(CompositorPref::Auto),
gamepad,
};
io::write_msg(&mut send, &welcome.encode()).await?;
@@ -434,6 +440,7 @@ async fn serve_session(
udp_port,
mode = ?hello.mode,
compositor = compositor.map(|c| c.id()).unwrap_or("none"),
gamepad = welcome.gamepad.as_str(),
"handshake complete — streaming"
);
@@ -483,9 +490,10 @@ async fn serve_session(
let (rich_tx, rich_rx) = std::sync::mpsc::channel::<punktfunk_core::quic::RichInput>();
let input_handle = {
let conn = conn.clone();
let gamepad = welcome.gamepad;
std::thread::Builder::new()
.name("punktfunk-m3-input".into())
.spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx))
.spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx, gamepad))
.context("spawn input thread")?
};
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
@@ -549,6 +557,37 @@ async fn serve_session(
None
};
// Test hook (synthetic source only): a scripted feedback burst on the host→client
// planes — rumble (0xCA) + DualSense HID-output (0xCD) — so loopback tests can assert
// the client's feedback path without a real game writing output reports to a real pad.
if opts.source == M3Source::Synthetic
&& std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
{
use punktfunk_core::quic::HidOutput;
let d = punktfunk_core::quic::encode_rumble_datagram(0, 0x4000, 0x8000);
let _ = conn.send_datagram(d.to_vec().into());
for h in [
HidOutput::Led {
pad: 0,
r: 10,
g: 20,
b: 30,
},
HidOutput::PlayerLeds {
pad: 0,
bits: 0b00100,
},
HidOutput::Trigger {
pad: 0,
which: 1,
effect: vec![0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
},
] {
let _ = conn.send_datagram(h.encode().into());
}
tracing::info!("PUNKTFUNK_TEST_FEEDBACK: scripted rumble + hidout burst sent");
}
// Data plane on a native thread (no async on the hot path — design invariant).
let cfg = welcome.session_config(Role::Host);
let source = opts.source;
@@ -854,12 +893,16 @@ enum PadBackend {
}
impl PadBackend {
fn select() -> PadBackend {
/// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference,
/// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only
/// ever construct the X-Box backend, whatever the resolution said.
fn select(kind: GamepadPref) -> PadBackend {
#[cfg(target_os = "linux")]
if std::env::var("PUNKTFUNK_GAMEPAD").as_deref() == Ok("dualsense") {
if kind == GamepadPref::DualSense {
tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)");
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new());
}
let _ = kind;
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new())
}
@@ -900,19 +943,20 @@ impl PadBackend {
}
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (uinput X-Box pads or,
/// with `PUNKTFUNK_GAMEPAD=dualsense`, virtual DualSense pads), with rich client→host input
/// (touchpad / motion, `rich_rx`) merged in and feedback pumped between events — rumble on the
/// universal datagram plane, DualSense LED/trigger feedback on the HID-output plane. The gamepads
/// are created and torn down with the session; the pointer/keyboard injector (and its portal
/// grant) lives in the service, across sessions.
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the
/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich
/// client→host input (touchpad / motion, `rich_rx`) merged in and feedback pumped between
/// events — rumble on the universal datagram plane, DualSense LED/trigger feedback on the
/// HID-output plane. The gamepads are created and torn down with the session; the
/// pointer/keyboard injector (and its portal grant) lives in the service, across sessions.
fn input_thread(
rx: std::sync::mpsc::Receiver<InputEvent>,
rich_rx: std::sync::mpsc::Receiver<punktfunk_core::quic::RichInput>,
conn: quinn::Connection,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref,
) {
let mut pads = PadBackend::select();
let mut pads = PadBackend::select(gamepad);
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
let mut pad_mask = 0u16;
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
@@ -1079,6 +1123,56 @@ fn synthetic_stream(session: &mut Session, frames: u32, stop: &AtomicBool) -> Re
Ok(())
}
/// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins,
/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360. The
/// DualSense backend needs Linux UHID — when unavailable any DualSense wish degrades to
/// X-Box 360 (never an error: a session without rich pads still streams).
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, dualsense_available: bool) -> GamepadPref {
let want = match pref {
GamepadPref::Auto => env
.and_then(GamepadPref::from_name)
.unwrap_or(GamepadPref::Auto),
explicit => explicit,
};
match want {
GamepadPref::DualSense if dualsense_available => GamepadPref::DualSense,
_ => GamepadPref::Xbox360,
}
}
/// Resolve the client's gamepad-backend preference (the env/logging shell around
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
let env = std::env::var("PUNKTFUNK_GAMEPAD").ok();
let chosen = pick_gamepad(pref, env.as_deref(), cfg!(target_os = "linux"));
match pref {
GamepadPref::Auto => {
// The operator's env knob deserves a diagnostic when it didn't drive the
// choice — a typo, or a DualSense wish on a non-UHID host, would otherwise
// degrade silently.
if let Some(env) = env.as_deref() {
if GamepadPref::from_name(env) != Some(chosen) {
tracing::warn!(
env,
chosen = chosen.as_str(),
"PUNKTFUNK_GAMEPAD unrecognized or unavailable — falling back"
);
}
}
tracing::info!(gamepad = chosen.as_str(), "gamepad backend (client: auto)")
}
want if want == chosen => {
tracing::info!(gamepad = chosen.as_str(), "honoring client gamepad request")
}
want => tracing::warn!(
requested = want.as_str(),
chosen = chosen.as_str(),
"client-requested gamepad backend unavailable — falling back"
),
}
chosen
}
/// Pure selection: choose the backend to drive from the client's `pref`, the set `available`
/// right now, and the auto-`detected` default. A concrete preference wins only if it's available;
/// otherwise (and for `Auto`) fall back to the detected default. `None` only when nothing is
@@ -1358,6 +1452,23 @@ mod tests {
);
}
#[test]
fn gamepad_resolution_precedence() {
use GamepadPref::*;
// An explicit client choice wins over the env var.
assert_eq!(pick_gamepad(DualSense, Some("xbox360"), true), DualSense);
assert_eq!(pick_gamepad(Xbox360, Some("dualsense"), true), Xbox360);
// Client Auto defers to the env var.
assert_eq!(pick_gamepad(Auto, Some("dualsense"), true), DualSense);
assert_eq!(pick_gamepad(Auto, Some("xbox360"), true), Xbox360);
// Auto + no env (or an unparseable one) → X-Box 360.
assert_eq!(pick_gamepad(Auto, None, true), Xbox360);
assert_eq!(pick_gamepad(Auto, Some("bogus"), true), Xbox360);
// DualSense degrades to X-Box 360 where the backend doesn't exist (non-Linux).
assert_eq!(pick_gamepad(DualSense, None, false), Xbox360);
assert_eq!(pick_gamepad(Auto, Some("dualsense"), false), Xbox360);
}
#[test]
fn permanent_errors_short_circuit_retry() {
// Permanent: config / version / missing-tool — retrying within a session can't fix these.
@@ -1650,6 +1761,7 @@ mod tests {
19778,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
None,
None,
timeout
@@ -1673,12 +1785,17 @@ mod tests {
19778,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
Some(host_fp),
Some((cert.clone(), key.clone())),
timeout,
)
.expect("paired session");
assert_eq!(client.host_fingerprint, host_fp);
// The Welcome always reports a CONCRETE resolved gamepad backend. (Not asserted
// against a specific one: resolve_gamepad honors an ambient PUNKTFUNK_GAMEPAD —
// a dev box exporting it must not fail the suite.)
assert_ne!(client.resolved_gamepad, GamepadPref::Auto);
drop(client);
host.join().unwrap().unwrap();