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:
@@ -27,10 +27,16 @@
|
||||
//! `gamescope`); the host honors it if available, else auto-detects and reports the resolved
|
||||
//! choice in its Welcome (logged as `session offer … compositor=…`).
|
||||
//!
|
||||
//! `--gamepad NAME` requests a host virtual-pad backend (`auto`|`xbox360`|`dualsense`); the
|
||||
//! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360,
|
||||
//! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`).
|
||||
//!
|
||||
//! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test]
|
||||
//! [--pin HEX] [--compositor NAME]` (M4 adds VAAPI decode + wgpu present on this skeleton.)
|
||||
//! [--pin HEX] [--compositor NAME] [--gamepad NAME]`
|
||||
//! (M4 adds VAAPI decode + wgpu present on this skeleton.)
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome};
|
||||
@@ -59,6 +65,8 @@ struct Args {
|
||||
name: String,
|
||||
/// `--compositor NAME` — request a host compositor backend (auto|kwin|wlroots|mutter|gamescope).
|
||||
compositor: CompositorPref,
|
||||
/// `--gamepad NAME` — request a host virtual-pad backend (auto|xbox360|dualsense).
|
||||
gamepad: GamepadPref,
|
||||
}
|
||||
|
||||
fn parse_mode(m: &str) -> Option<Mode> {
|
||||
@@ -145,6 +153,17 @@ fn parse_args() -> Args {
|
||||
}
|
||||
},
|
||||
};
|
||||
// Same fail-closed discipline for --gamepad.
|
||||
let gamepad = match get("--gamepad") {
|
||||
None => GamepadPref::Auto,
|
||||
Some(s) => match GamepadPref::from_name(s) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
eprintln!("--gamepad must be one of: auto, xbox360, dualsense");
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
};
|
||||
Args {
|
||||
connect: get("--connect").unwrap_or("127.0.0.1:9777").to_string(),
|
||||
mode,
|
||||
@@ -158,6 +177,7 @@ fn parse_args() -> Args {
|
||||
pair: get("--pair").map(String::from),
|
||||
name: get("--name").unwrap_or("punktfunk-client-rs").to_string(),
|
||||
compositor,
|
||||
gamepad,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +262,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
mode: args.mode,
|
||||
compositor: args.compositor,
|
||||
gamepad: args.gamepad,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -254,6 +275,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
encrypt = welcome.encrypt,
|
||||
frames = welcome.frames,
|
||||
compositor = welcome.compositor.as_str(),
|
||||
gamepad = welcome.gamepad.as_str(),
|
||||
"session offer"
|
||||
);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ parse_deps = false
|
||||
"BTN_B" = "PUNKTFUNK_BTN_B"
|
||||
"BTN_X" = "PUNKTFUNK_BTN_X"
|
||||
"BTN_Y" = "PUNKTFUNK_BTN_Y"
|
||||
"BTN_TOUCHPAD" = "PUNKTFUNK_BTN_TOUCHPAD"
|
||||
"AXIS_LS_X" = "PUNKTFUNK_AXIS_LS_X"
|
||||
"AXIS_LS_Y" = "PUNKTFUNK_AXIS_LS_Y"
|
||||
"AXIS_RS_X" = "PUNKTFUNK_AXIS_RS_X"
|
||||
|
||||
@@ -621,6 +621,19 @@ pub const PUNKTFUNK_COMPOSITOR_MUTTER: u32 = 3;
|
||||
/// gamescope (spawned nested).
|
||||
pub const PUNKTFUNK_COMPOSITOR_GAMESCOPE: u32 = 4;
|
||||
|
||||
/// Gamepad-backend preference for [`punktfunk_connect_ex2`] (`gamepad` arg): which virtual pad
|
||||
/// the host creates for this session's controllers. Precedence host-side: an explicit client
|
||||
/// choice > the host's `PUNKTFUNK_GAMEPAD` env var > X-Box 360. `AUTO` (or any unrecognized
|
||||
/// value) = host decides. The resolved choice is echoed over the protocol (`Welcome`) and
|
||||
/// readable via [`punktfunk_connection_gamepad`].
|
||||
pub const PUNKTFUNK_GAMEPAD_AUTO: u32 = 0;
|
||||
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
|
||||
pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
|
||||
/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
|
||||
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
|
||||
/// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
||||
|
||||
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
/// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
|
||||
@@ -674,6 +687,7 @@ pub unsafe extern "C" fn punktfunk_connect(
|
||||
/// the `PUNKTFUNK_COMPOSITOR_*` values). `PUNKTFUNK_COMPOSITOR_AUTO` (or any unrecognized value)
|
||||
/// lets the host decide; a concrete value is honored only if available, else the host falls back
|
||||
/// to auto-detect. The resolved choice is logged host-side and returned over the protocol.
|
||||
/// Equivalent to [`punktfunk_connect_ex2`] with `gamepad = PUNKTFUNK_GAMEPAD_AUTO`.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
@@ -691,6 +705,49 @@ pub unsafe extern "C" fn punktfunk_connect_ex(
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
unsafe {
|
||||
punktfunk_connect_ex2(
|
||||
host,
|
||||
port,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
PUNKTFUNK_GAMEPAD_AUTO,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
timeout_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`punktfunk_connect_ex`], but additionally requests which virtual `gamepad` backend the
|
||||
/// host creates for this session's pads (one of the `PUNKTFUNK_GAMEPAD_*` values).
|
||||
/// `PUNKTFUNK_GAMEPAD_AUTO` (or any unrecognized value) lets the host decide (its
|
||||
/// `PUNKTFUNK_GAMEPAD` env var, else X-Box 360); a concrete value is honored only if that
|
||||
/// backend is available on the host. The resolved choice is readable via
|
||||
/// [`punktfunk_connection_gamepad`] — only a DualSense session emits HID-output feedback.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex2(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
if host.is_null() {
|
||||
@@ -705,7 +762,14 @@ pub unsafe extern "C" fn punktfunk_connect_ex(
|
||||
height,
|
||||
refresh_hz,
|
||||
};
|
||||
let pref = crate::config::CompositorPref::from_u8(compositor as u8);
|
||||
// "Any unrecognized value = Auto" must hold for the FULL u32 domain — `as u8`
|
||||
// would wrap 0x101 into a concrete choice before from_u8's fallback could apply.
|
||||
let pref = u8::try_from(compositor)
|
||||
.map(crate::config::CompositorPref::from_u8)
|
||||
.unwrap_or_default();
|
||||
let gamepad = u8::try_from(gamepad)
|
||||
.map(crate::config::GamepadPref::from_u8)
|
||||
.unwrap_or_default();
|
||||
let pin = if pin_sha256.is_null() {
|
||||
None
|
||||
} else {
|
||||
@@ -725,6 +789,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex(
|
||||
port,
|
||||
mode,
|
||||
pref,
|
||||
gamepad,
|
||||
pin,
|
||||
identity,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
@@ -1153,6 +1218,33 @@ pub unsafe extern "C" fn punktfunk_connection_mode(
|
||||
})
|
||||
}
|
||||
|
||||
/// The virtual gamepad backend the host actually resolved for this session (one of the
|
||||
/// `PUNKTFUNK_GAMEPAD_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex2`]
|
||||
/// preference). `PUNKTFUNK_GAMEPAD_AUTO` = an older host that didn't say — assume X-Box 360,
|
||||
/// no HID-output feedback. Safe any time after connect.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `gamepad` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_gamepad(
|
||||
c: *const PunktfunkConnection,
|
||||
gamepad: *mut u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
unsafe {
|
||||
if !gamepad.is_null() {
|
||||
*gamepad = c.inner.resolved_gamepad.to_u8() as u32;
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
||||
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
||||
/// stream continues at the new mode — the first new-mode access unit is an IDR with
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! invariant) plus a blocking data-plane pump; frames cross to the embedder over a bounded
|
||||
//! channel. All methods are safe to call from any single embedder thread.
|
||||
|
||||
use crate::config::{CompositorPref, Mode, Role};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode, Role};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{
|
||||
@@ -71,6 +71,9 @@ pub struct NativeClient {
|
||||
/// SHA-256 fingerprint of the certificate the host actually presented. A TOFU caller
|
||||
/// (`pin = None`) persists this and passes it as the pin from then on.
|
||||
pub host_fingerprint: [u8; 32],
|
||||
/// The virtual gamepad backend the host actually resolved ([`Welcome::gamepad`]).
|
||||
/// `Auto` = an older host that didn't say (assume X-Box 360, no DualSense feedback).
|
||||
pub resolved_gamepad: GamepadPref,
|
||||
}
|
||||
|
||||
impl NativeClient {
|
||||
@@ -84,11 +87,13 @@ impl NativeClient {
|
||||
/// `identity`: this client's persistent self-signed identity (PEM cert + PKCS#8 key,
|
||||
/// see [`endpoint::generate_identity`]), presented via TLS client auth so a host can
|
||||
/// recognize a paired client. `None` = anonymous (rejected by hosts requiring pairing).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn connect(
|
||||
host: &str,
|
||||
port: u16,
|
||||
mode: Mode,
|
||||
compositor: CompositorPref,
|
||||
gamepad: GamepadPref,
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
timeout: Duration,
|
||||
@@ -101,7 +106,8 @@ impl NativeClient {
|
||||
let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec<u8>)>();
|
||||
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||
let (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::<Mode>();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(Mode, [u8; 32])>>();
|
||||
let (ready_tx, ready_rx) =
|
||||
std::sync::mpsc::channel::<Result<(Mode, GamepadPref, [u8; 32])>>();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
||||
|
||||
@@ -127,6 +133,7 @@ impl NativeClient {
|
||||
port,
|
||||
mode,
|
||||
compositor,
|
||||
gamepad,
|
||||
pin,
|
||||
identity,
|
||||
frame_tx,
|
||||
@@ -144,7 +151,7 @@ impl NativeClient {
|
||||
})
|
||||
.map_err(PunktfunkError::Io)?;
|
||||
|
||||
let (negotiated, fingerprint) = match ready_rx.recv_timeout(timeout) {
|
||||
let (negotiated, resolved_gamepad, fingerprint) = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
@@ -166,6 +173,7 @@ impl NativeClient {
|
||||
worker: Some(worker),
|
||||
mode: mode_slot,
|
||||
host_fingerprint: fingerprint,
|
||||
resolved_gamepad,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -364,6 +372,7 @@ struct WorkerArgs {
|
||||
port: u16,
|
||||
mode: Mode,
|
||||
compositor: CompositorPref,
|
||||
gamepad: GamepadPref,
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
frame_tx: SyncSender<Frame>,
|
||||
@@ -374,7 +383,7 @@ struct WorkerArgs {
|
||||
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||
reconfig_rx: tokio::sync::mpsc::UnboundedReceiver<Mode>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, [u8; 32])>>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, GamepadPref, [u8; 32])>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||
}
|
||||
@@ -387,6 +396,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
port,
|
||||
mode,
|
||||
compositor,
|
||||
gamepad,
|
||||
pin,
|
||||
identity,
|
||||
frame_tx,
|
||||
@@ -437,6 +447,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
abi_version: crate::ABI_VERSION,
|
||||
mode,
|
||||
compositor,
|
||||
gamepad,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -448,6 +459,12 @@ async fn worker_main(args: WorkerArgs) {
|
||||
"host resolved compositor"
|
||||
);
|
||||
}
|
||||
if welcome.gamepad != GamepadPref::Auto {
|
||||
tracing::info!(
|
||||
gamepad = welcome.gamepad.as_str(),
|
||||
"host resolved gamepad backend"
|
||||
);
|
||||
}
|
||||
|
||||
// Reserve our data-plane port, then start the host.
|
||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||
@@ -466,18 +483,33 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let transport =
|
||||
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
|
||||
let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
|
||||
Ok::<_, PunktfunkError>((conn, session, send, recv, welcome.mode, fingerprint))
|
||||
Ok::<_, PunktfunkError>((
|
||||
conn,
|
||||
session,
|
||||
send,
|
||||
recv,
|
||||
welcome.mode,
|
||||
welcome.gamepad,
|
||||
fingerprint,
|
||||
))
|
||||
};
|
||||
|
||||
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, fingerprint) =
|
||||
match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready_tx.send(Ok((negotiated, fingerprint)));
|
||||
let (
|
||||
conn,
|
||||
mut session,
|
||||
mut ctrl_send,
|
||||
mut ctrl_recv,
|
||||
negotiated,
|
||||
resolved_gamepad,
|
||||
fingerprint,
|
||||
) = match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready_tx.send(Ok((negotiated, resolved_gamepad, fingerprint)));
|
||||
|
||||
// Input task: embedder events → QUIC datagrams.
|
||||
let input_conn = conn.clone();
|
||||
|
||||
@@ -130,6 +130,67 @@ impl CompositorPref {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which virtual gamepad the host should create for a client's pads.
|
||||
///
|
||||
/// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the
|
||||
/// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the
|
||||
/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is
|
||||
/// honored only if that backend is available on the host (DualSense needs Linux UHID); otherwise
|
||||
/// the host falls back and reports the real choice in `Welcome`. The wire form is a single byte
|
||||
/// (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`), appended to `Hello`/`Welcome` — older peers
|
||||
/// simply omit/ignore it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GamepadPref {
|
||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
||||
#[default]
|
||||
Auto,
|
||||
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
|
||||
Xbox360,
|
||||
/// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion.
|
||||
DualSense,
|
||||
}
|
||||
|
||||
impl GamepadPref {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`.
|
||||
pub fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
GamepadPref::Xbox360 => 1,
|
||||
GamepadPref::DualSense => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`to_u8`](Self::to_u8). An unknown byte decodes to `Auto` — forward-compatible:
|
||||
/// a future concrete value a peer doesn't recognize degrades to "let the host decide".
|
||||
pub fn from_u8(v: u8) -> Self {
|
||||
match v {
|
||||
1 => GamepadPref::Xbox360,
|
||||
2 => GamepadPref::DualSense,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CLI/config name (case-insensitive, with the usual aliases). `None` for an
|
||||
/// unrecognized name, so callers can error rather than silently defaulting to `Auto`.
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
Some(match s.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" | "default" => GamepadPref::Auto,
|
||||
"xbox" | "xbox360" | "x360" | "uinput" => GamepadPref::Xbox360,
|
||||
"dualsense" | "ds" | "ps5" => GamepadPref::DualSense,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
GamepadPref::Xbox360 => "xbox360",
|
||||
GamepadPref::DualSense => "dualsense",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-block FEC parameters. Recovery count is derived from `fec_percent` exactly as
|
||||
/// GameStream does: `m = ceil(k * fec_percent / 100)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -63,6 +63,10 @@ pub mod gamepad {
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
/// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||
/// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
||||
/// the same bit. Only the DualSense backend renders it; the xpad has no such button.
|
||||
pub const BTN_TOUCHPAD: u32 = 0x10_0000;
|
||||
|
||||
/// Axis ids for `InputKind::GamepadAxis`.
|
||||
pub const AXIS_LS_X: u32 = 0;
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
//! reported back for persisting). The data plane adds AES-GCM on top.
|
||||
//! All integers little-endian; every message is `u16 length || payload`.
|
||||
|
||||
use crate::config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
|
||||
use crate::config::{
|
||||
CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role,
|
||||
};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
|
||||
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
|
||||
@@ -45,6 +47,11 @@ pub struct Hello {
|
||||
/// choice in [`Welcome::compositor`]. Appended to the wire form — omitted by older clients
|
||||
/// (decodes to `Auto`).
|
||||
pub compositor: CompositorPref,
|
||||
/// Which virtual gamepad the host should create for this session's pads (`Auto` = host
|
||||
/// decides: its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). Resolved choice echoed in
|
||||
/// [`Welcome::gamepad`]. Appended to the wire form — omitted by older clients (decodes
|
||||
/// to `Auto`).
|
||||
pub gamepad: GamepadPref,
|
||||
}
|
||||
|
||||
/// `host → client`: the complete session offer.
|
||||
@@ -65,6 +72,11 @@ pub struct Welcome {
|
||||
/// [`Hello::compositor`] preference if available, else the host's auto-detected choice).
|
||||
/// Appended to the wire form — `Auto` when an older host omitted it (i.e. "unknown").
|
||||
pub compositor: CompositorPref,
|
||||
/// The virtual gamepad backend the host actually resolved (the client's [`Hello::gamepad`]
|
||||
/// preference if available, else env var / X-Box 360). A client uses this to know whether
|
||||
/// DualSense feedback (0xCD) can arrive at all. Appended to the wire form — `Auto` when an
|
||||
/// older host omitted it (i.e. "unknown, assume X-Box 360").
|
||||
pub gamepad: GamepadPref,
|
||||
}
|
||||
|
||||
/// `client → host`: data plane is bound, begin streaming.
|
||||
@@ -359,13 +371,14 @@ pub mod pake {
|
||||
|
||||
impl Hello {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(21);
|
||||
let mut b = Vec::with_capacity(22);
|
||||
b.extend_from_slice(MAGIC);
|
||||
b.extend_from_slice(&self.abi_version.to_le_bytes());
|
||||
b.extend_from_slice(&self.mode.width.to_le_bytes());
|
||||
b.extend_from_slice(&self.mode.height.to_le_bytes());
|
||||
b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes());
|
||||
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
|
||||
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
|
||||
b
|
||||
}
|
||||
|
||||
@@ -381,11 +394,15 @@ impl Hello {
|
||||
height: u32at(12),
|
||||
refresh_hz: u32at(16),
|
||||
},
|
||||
// Optional trailing byte — an older client that omits it requests `Auto`.
|
||||
// Optional trailing bytes — an older client that omits them requests `Auto`.
|
||||
compositor: b
|
||||
.get(20)
|
||||
.map(|&v| CompositorPref::from_u8(v))
|
||||
.unwrap_or_default(),
|
||||
gamepad: b
|
||||
.get(21)
|
||||
.map(|&v| GamepadPref::from_u8(v))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -411,13 +428,14 @@ impl Welcome {
|
||||
b.extend_from_slice(&self.salt);
|
||||
b.extend_from_slice(&self.frames.to_le_bytes());
|
||||
b.push(self.compositor.to_u8()); // appended at offset 53 — older clients read [0..53] and skip it
|
||||
b.push(self.gamepad.to_u8()); // appended at offset 54 — same back-compat discipline
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<Welcome> {
|
||||
// Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22]
|
||||
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
||||
// salt[45..49] frames[49..53] compositor[53] (optional trailing byte).
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] (optional trailing bytes).
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -449,12 +467,16 @@ impl Welcome {
|
||||
key,
|
||||
salt,
|
||||
frames: u32at(49),
|
||||
// Optional trailing byte — an older host that omits it leaves the resolved
|
||||
// compositor unknown (`Auto`).
|
||||
// Optional trailing bytes — an older host that omits them leaves the resolved
|
||||
// compositor / gamepad backend unknown (`Auto`).
|
||||
compositor: b
|
||||
.get(53)
|
||||
.map(|&v| CompositorPref::from_u8(v))
|
||||
.unwrap_or_default(),
|
||||
gamepad: b
|
||||
.get(54)
|
||||
.map(|&v| GamepadPref::from_u8(v))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1126,6 +1148,7 @@ mod tests {
|
||||
salt: [1, 2, 3, 4],
|
||||
frames: 600,
|
||||
compositor: CompositorPref::Gamescope,
|
||||
gamepad: GamepadPref::DualSense,
|
||||
};
|
||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||
}
|
||||
@@ -1140,6 +1163,7 @@ mod tests {
|
||||
refresh_hz: 120,
|
||||
},
|
||||
compositor: CompositorPref::Kwin,
|
||||
gamepad: GamepadPref::DualSense,
|
||||
};
|
||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||
let s = Start {
|
||||
@@ -1171,11 +1195,29 @@ mod tests {
|
||||
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_pref_wire_and_names() {
|
||||
for p in [
|
||||
GamepadPref::Auto,
|
||||
GamepadPref::Xbox360,
|
||||
GamepadPref::DualSense,
|
||||
] {
|
||||
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||
}
|
||||
// Aliases + unknowns.
|
||||
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||
assert_eq!(GamepadPref::from_name("nope"), None);
|
||||
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_welcome_compositor_back_compat() {
|
||||
// A new client/host appends one byte; the field is optional on decode, so a legacy
|
||||
// peer's shorter message still decodes (compositor = Auto), and a legacy peer reading a
|
||||
// new message ignores the trailing byte. Simulate both directions by truncation.
|
||||
// Trailing optional bytes (compositor at 20/53, gamepad at 21/54): a legacy peer's
|
||||
// shorter message still decodes (missing fields = Auto), and a legacy peer reading a
|
||||
// new message ignores the trailing bytes. Simulate both directions by truncation.
|
||||
let h = Hello {
|
||||
abi_version: 2,
|
||||
mode: Mode {
|
||||
@@ -1184,13 +1226,19 @@ mod tests {
|
||||
refresh_hz: 60,
|
||||
},
|
||||
compositor: CompositorPref::Mutter,
|
||||
gamepad: GamepadPref::DualSense,
|
||||
};
|
||||
let enc = h.encode();
|
||||
assert_eq!(enc.len(), 21);
|
||||
// Legacy (20-byte) Hello → Auto, mode intact.
|
||||
assert_eq!(enc.len(), 22);
|
||||
// Legacy (20-byte) Hello → both Auto, mode intact.
|
||||
let legacy = Hello::decode(&enc[..20]).unwrap();
|
||||
assert_eq!(legacy.compositor, CompositorPref::Auto);
|
||||
assert_eq!(legacy.gamepad, GamepadPref::Auto);
|
||||
assert_eq!(legacy.mode, h.mode);
|
||||
// Compositor-era (21-byte) Hello → compositor intact, gamepad Auto.
|
||||
let mid = Hello::decode(&enc[..21]).unwrap();
|
||||
assert_eq!(mid.compositor, CompositorPref::Mutter);
|
||||
assert_eq!(mid.gamepad, GamepadPref::Auto);
|
||||
|
||||
let w = Welcome {
|
||||
abi_version: 2,
|
||||
@@ -1207,13 +1255,18 @@ mod tests {
|
||||
salt: [9, 8, 7, 6],
|
||||
frames: 0,
|
||||
compositor: CompositorPref::Kwin,
|
||||
gamepad: GamepadPref::Xbox360,
|
||||
};
|
||||
let wenc = w.encode();
|
||||
assert_eq!(wenc.len(), 54);
|
||||
assert_eq!(wenc.len(), 55);
|
||||
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||
assert_eq!(legacy_w.frames, 0);
|
||||
assert_eq!(legacy_w.key, w.key);
|
||||
let mid_w = Welcome::decode(&wenc[..54]).unwrap();
|
||||
assert_eq!(mid_w.compositor, CompositorPref::Kwin);
|
||||
assert_eq!(mid_w.gamepad, GamepadPref::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1257,6 +1310,7 @@ mod tests {
|
||||
refresh_hz: 60,
|
||||
},
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
}
|
||||
.encode();
|
||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||
|
||||
@@ -106,8 +106,6 @@ mod btn1 {
|
||||
/// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits).
|
||||
mod btn2 {
|
||||
pub const PS: u8 = 0x01;
|
||||
/// Set from a touchpad-press rich event (no equivalent on the GameStream xpad).
|
||||
#[allow(dead_code)]
|
||||
pub const TOUCHPAD: u8 = 0x02;
|
||||
#[allow(dead_code)]
|
||||
pub const MUTE: u8 = 0x04;
|
||||
@@ -227,6 +225,9 @@ impl DsState {
|
||||
if on(gs::BTN_GUIDE) {
|
||||
s.buttons[2] |= btn2::PS;
|
||||
}
|
||||
if on(gs::BTN_TOUCHPAD) {
|
||||
s.buttons[2] |= btn2::TOUCHPAD;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -247,10 +248,40 @@ impl DsState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a full input report `0x01` (pure — unit-testable without `/dev/uhid`). Field
|
||||
/// offsets per the kernel's `struct dualsense_input_report`, this report's one consumer:
|
||||
/// x..rz 0-5, seq 6, buttons[4] 7-10, reserved[4] 11-14, gyro[3] 15-20, accel[3] 21-26,
|
||||
/// sensor_timestamp 27-30, reserved2 31, points[2] 32-39 (static_assert(sizeof == 63)).
|
||||
/// The report id occupies r[0], so struct offset N = r[N + 1].
|
||||
fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8, ts: u32) {
|
||||
r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1])
|
||||
r[1] = st.lx;
|
||||
r[2] = st.ly;
|
||||
r[3] = st.rx;
|
||||
r[4] = st.ry;
|
||||
r[5] = st.l2;
|
||||
r[6] = st.r2;
|
||||
r[7] = seq; // seq_number (struct off 6)
|
||||
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
||||
r[9] = st.buttons[1]; // off 8
|
||||
r[10] = st.buttons[2]; // off 9
|
||||
r[11] = st.buttons[3]; // off 10
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
|
||||
}
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[22 + i * 2..24 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 21
|
||||
}
|
||||
r[28..32].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 27)
|
||||
pack_touch(&mut r[33..37], &st.touch[0]); // touch point 1 (struct off 32)
|
||||
pack_touch(&mut r[37..41], &st.touch[1]); // touch point 2
|
||||
}
|
||||
|
||||
fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
// byte0: bit7 = NOT active (1 = no contact), bits0-6 = contact id.
|
||||
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
|
||||
let (x, y) = (t.x.min(DS_TOUCH_W), t.y.min(DS_TOUCH_H));
|
||||
// The kernel advertises ABS_MT ranges 0..=W-1 / 0..=H-1 — never emit the size itself.
|
||||
let (x, y) = (t.x.min(DS_TOUCH_W - 1), t.y.min(DS_TOUCH_H - 1));
|
||||
dst[1] = (x & 0xFF) as u8;
|
||||
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
|
||||
dst[3] = ((y >> 4) & 0xFF) as u8;
|
||||
@@ -317,30 +348,10 @@ impl DualSensePad {
|
||||
|
||||
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
|
||||
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1])
|
||||
r[1] = st.lx;
|
||||
r[2] = st.ly;
|
||||
r[3] = st.rx;
|
||||
r[4] = st.ry;
|
||||
r[5] = st.l2;
|
||||
r[6] = st.r2;
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
r[7] = self.seq; // seq_number (struct off 6)
|
||||
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
||||
r[9] = st.buttons[1]; // off 8
|
||||
r[10] = st.buttons[2]; // off 9
|
||||
r[11] = st.buttons[3]; // off 10
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[15 + i * 2..17 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 14
|
||||
}
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[21 + i * 2..23 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 20
|
||||
}
|
||||
self.ts = self.ts.wrapping_add(1); // monotonic sensor timestamp is all the kernel needs
|
||||
r[27..31].copy_from_slice(&self.ts.to_le_bytes()); // sensor_timestamp (struct off 26)
|
||||
pack_touch(&mut r[34..38], &st.touch[0]); // touch point 1 (struct off 33)
|
||||
pack_touch(&mut r[38..42], &st.touch[1]); // touch point 2
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.seq, self.ts);
|
||||
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
@@ -413,38 +424,55 @@ impl Drop for DualSensePad {
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
///
|
||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||
/// at data[2]) — writers only set the bits for fields they mean to change (the kernel zeroes
|
||||
/// the rest), so an ungated parse would turn every plain rumble write into a lightbar-off +
|
||||
/// triggers-off broadcast.
|
||||
fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
// data[0] is the report id (0x02). Be defensive about short reports.
|
||||
if data.first() != Some(&0x02) || data.len() < 48 {
|
||||
return;
|
||||
}
|
||||
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA). We don't gate on the report's valid-flags
|
||||
// (matching the LED/trigger handling) — the manager only forwards a *change*, so a report
|
||||
// that touches only the LED doesn't spam a rumble-stop.
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
if flag0 & 0x03 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
|
||||
let (r, g, b) = (data[45], data[46], data[47]);
|
||||
fb.hidout.push(HidOutput::Led { pad, r, g, b });
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: data[44] & 0x1F,
|
||||
});
|
||||
// Adaptive-trigger parameter blocks: L2 at bytes 11..22, R2 at 22..33 (11 bytes each).
|
||||
if flag1 & 0x04 != 0 {
|
||||
let (r, g, b) = (data[45], data[46], data[47]);
|
||||
fb.hidout.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if flag1 & 0x10 != 0 {
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: data[44] & 0x1F,
|
||||
});
|
||||
}
|
||||
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
|
||||
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
|
||||
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= 33 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 0,
|
||||
effect: data[11..22].to_vec(),
|
||||
});
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 1,
|
||||
effect: data[22..33].to_vec(),
|
||||
});
|
||||
if flag0 & 0x04 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 1,
|
||||
effect: data[11..22].to_vec(),
|
||||
});
|
||||
}
|
||||
if flag0 & 0x08 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 0,
|
||||
effect: data[22..33].to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,9 +581,10 @@ impl DualSenseManager {
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
// Normalized 0..=65535 → the touchpad's reported resolution.
|
||||
t.x = ((x as u32 * DS_TOUCH_W as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * DS_TOUCH_H as u32) / u16::MAX as u32) as u16;
|
||||
// Normalized 0..=65535 → the touchpad's coordinate range (0..=W-1 / 0..=H-1,
|
||||
// what the kernel advertises as the ABS_MT extents).
|
||||
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
@@ -621,14 +650,19 @@ impl DualSenseManager {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A DualSense USB output report (`0x02`) parses into motor rumble (0xCA), lightbar, player
|
||||
/// LEDs, and both adaptive-trigger blocks (0xCD).
|
||||
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
|
||||
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
|
||||
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
|
||||
#[test]
|
||||
fn parse_output_report() {
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02; // report id
|
||||
data[1] = 0x0F; // valid_flag0: vibration + haptics + R2 + L2 triggers
|
||||
data[2] = 0x14; // valid_flag1: lightbar + player indicators
|
||||
data[3] = 0x80; // right (high-freq) motor
|
||||
data[4] = 0x40; // left (low-freq) motor
|
||||
data[11] = 0x21; // right-trigger block mode byte (report bytes 11..22)
|
||||
data[22] = 0x26; // left-trigger block mode byte (report bytes 22..33)
|
||||
data[44] = 0x03; // player LEDs (low 5 bits)
|
||||
data[45] = 10; // R
|
||||
data[46] = 20; // G
|
||||
@@ -646,13 +680,86 @@ mod tests {
|
||||
assert!(fb
|
||||
.hidout
|
||||
.contains(&HidOutput::PlayerLeds { pad: 0, bits: 3 }));
|
||||
assert_eq!(
|
||||
fb.hidout
|
||||
.iter()
|
||||
.filter(|h| matches!(h, HidOutput::Trigger { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
// The report's FIRST block (bytes 11..22) is the RIGHT trigger → wire which = 1.
|
||||
let triggers: Vec<_> = fb
|
||||
.hidout
|
||||
.iter()
|
||||
.filter_map(|h| match h {
|
||||
HidOutput::Trigger { which, effect, .. } => Some((*which, effect[0])),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(triggers, vec![(1, 0x21), (0, 0x26)]);
|
||||
}
|
||||
|
||||
/// Writers set only the valid-flag bits for the fields they mean to change (the kernel
|
||||
/// zeroes the rest of the report) — a plain rumble write must NOT blank the lightbar /
|
||||
/// player LEDs / triggers, and an LED-only write must not stop the motors.
|
||||
#[test]
|
||||
fn parse_output_respects_valid_flags() {
|
||||
// Kernel-style rumble write: only the vibration flags set, everything else zero.
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[1] = 0x03; // compatible vibration + haptics select
|
||||
data[3] = 0xFF;
|
||||
data[4] = 0xFF;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert_eq!(fb.rumble, Some((0xFF00, 0xFF00)));
|
||||
assert!(fb.hidout.is_empty(), "rumble write must not emit hidout");
|
||||
|
||||
// Lightbar-only write: no rumble surfaced (would otherwise spam rumble-stops).
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[2] = 0x04; // lightbar control enable
|
||||
data[45] = 1;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert!(fb.rumble.is_none());
|
||||
assert_eq!(fb.hidout.len(), 1);
|
||||
assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. }));
|
||||
}
|
||||
|
||||
/// The input report's sensor/touch bytes must land exactly where the kernel's
|
||||
/// `struct dualsense_input_report` reads them (gyro at struct offset 15, accel 21,
|
||||
/// timestamp 27, touch points 32 — report byte = struct offset + 1). A one-byte slip
|
||||
/// here turns client motion into noise and conjures phantom touch contacts.
|
||||
#[test]
|
||||
fn input_report_layout_matches_hid_playstation() {
|
||||
let mut st = DsState::neutral();
|
||||
st.gyro = [0x1122, 0x3344, 0x5566];
|
||||
st.accel = [0x778, 0x99A, 0xBBC];
|
||||
st.touch[0] = Touch {
|
||||
active: true,
|
||||
id: 5,
|
||||
x: 0x123,
|
||||
y: 0x356,
|
||||
};
|
||||
// touch[1] stays inactive — its NOT-active bit must be set.
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &st, 7, 0xAABBCCDD);
|
||||
assert_eq!(r[0], 0x01);
|
||||
assert_eq!(r[7], 7); // seq_number (struct off 6)
|
||||
assert_eq!(&r[16..22], &[0x22, 0x11, 0x44, 0x33, 0x66, 0x55]); // gyro LE
|
||||
assert_eq!(&r[22..28], &[0x78, 0x07, 0x9A, 0x09, 0xBC, 0x0B]); // accel LE
|
||||
assert_eq!(&r[28..32], &[0xDD, 0xCC, 0xBB, 0xAA]); // sensor_timestamp LE
|
||||
// Touch point 1 at struct off 32 = r[33..37]: contact byte (active → bit7 clear),
|
||||
// then 12-bit x / 12-bit y packed.
|
||||
assert_eq!(r[33], 5);
|
||||
assert_eq!(r[34], 0x23);
|
||||
assert_eq!(r[35], 0x61); // x_hi nibble 0x1 | (y & 0xF) << 4 (y=0x356 → 0x6 << 4)
|
||||
assert_eq!(r[36], 0x35); // y >> 4
|
||||
assert_eq!(r[37] & 0x80, 0x80); // touch point 2 inactive
|
||||
}
|
||||
|
||||
/// The wire touchpad-click bit (Moonlight's extended position) lands in `buttons[2]`.
|
||||
#[test]
|
||||
fn from_gamepad_maps_touchpad_click() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD);
|
||||
let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(s.buttons[2], 0);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
|
||||
+129
-12
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user