diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index de8e271..978acc1 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -50,15 +50,25 @@ object Gamepad { const val PREF_DUALSENSE = 2 const val PREF_XBOXONE = 3 const val PREF_DUALSHOCK4 = 4 + const val PREF_STEAMCONTROLLER = 5 + const val PREF_STEAMDECK = 6 // USB vendor ids of the controllers we can identify by VID/PID. private const val VID_SONY = 0x054C private const val VID_MICROSOFT = 0x045E + private const val VID_VALVE = 0x28DE // Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types. private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2) private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC) + // Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) / + // dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles / + // trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard + // buttons + sticks reach the host for now — parity with the desktop type resolution. + private val PID_STEAMDECK = setOf(0x1205) + private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142) + // Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All // behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte. private val PID_XBOXONE = setOf( @@ -82,6 +92,8 @@ object Gamepad { vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4 vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE + vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK + vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER else -> PREF_XBOX360 } } diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift index fa6b718..17488d7 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift @@ -182,6 +182,11 @@ public final class PunktfunkConnection { case dualSense = 2 case xboxOne = 3 case dualShock4 = 4 + // Valve Steam Controller / Steam Deck (Linux UHID hid-steam hosts). Parity only on Apple — + // GameController never surfaces a 0x28DE HID device, so the client can't capture one; these + // exist so the resolved type round-trips and name parsing matches the host. + case steamController = 5 + case steamDeck = 6 /// Loose name parsing for env/dev hooks, mirroring the host's /// `GamepadPref::from_name`. @@ -192,6 +197,8 @@ public final class PunktfunkConnection { case "dualsense", "ds", "ds5", "ps5": self = .dualSense case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4 + case "steamdeck", "steam-deck", "deck": self = .steamDeck + case "steamcontroller", "steam-controller", "steamcon": self = .steamController default: return nil } } diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 910a685..733617c 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -294,7 +294,13 @@ const RESOLUTIONS: [number, number, string][] = [ [2560, 1440, "2560 × 1440"], ]; const REFRESH = [0, 30, 60, 90, 120]; -const GAMEPADS = ["auto", "xbox360", "dualsense"]; +const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"]; +const GAMEPAD_LABELS: Record = { + auto: "Automatic", + xbox360: "Xbox 360", + dualsense: "DualSense", + steamdeck: "Steam Deck", +}; const SettingsSection: FC = () => { const [s, setS] = useState(null); @@ -355,14 +361,17 @@ const SettingsSection: FC = () => { /> ({ - data: g, - label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense", - }))} + rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} selectedOption={s.gamepad} onChange={(o) => patch({ gamepad: o.data as string })} /> + {s.gamepad === "steamdeck" && ( + + )} { return appId; } +/** + * Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the + * Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient + * API for this, so it is feature-detected and MUST never block or throw into the launch — the manual + * toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is + * the documented source of truth. No-op when the optional API is absent. + */ +function disableSteamInputForShortcut(appId: number): void { + try { + const input = ( + SteamClient as unknown as { + Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void }; + } + ).Input; + input?.SetSteamInputEnabledForApp?.(appId, false); + } catch { + /* a controller tweak must never break the launch */ + } +} + /** * Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the * shortcut's launch options (so one generic shortcut serves every host), then RunGame. */ export async function launchStream(host: string, port: number): Promise { const appId = await ensureShortcut(); + // Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user + // disables Steam Input manually — see the Settings instruction). + disableSteamInputForShortcut(appId); const target = port && port !== 9777 ? `${host}:${port}` : host; // KEY=value ... %command% — the wrapper reads PF_HOST from the environment. SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`); diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index d3cf7e1..e4dcace 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -692,6 +692,77 @@ impl PunktfunkRichInput { } } +/// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam +/// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and +/// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a +/// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew +/// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] + +/// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers. +#[cfg(feature = "quic")] +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PunktfunkRichInputEx { + /// MUST equal `sizeof(PunktfunkRichInputEx)`. + pub struct_size: u32, + /// One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`). + pub kind: u8, + /// Gamepad index. + pub pad: u8, + /// Touchpad/TouchpadEx: contact id. + pub finger: u8, + /// Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted. + pub active: u8, + /// TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad. + pub surface: u8, + /// TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact. + pub click: u8, + /// Reserved for alignment; set to 0. + pub _reserved: [u8; 2], + /// TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a + /// legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits. + pub x: i16, + /// TouchpadEx: y coordinate — signed, centred at 0. + pub y: i16, + /// TouchpadEx: contact pressure (`0` if the surface has no force sensor). + pub pressure: u16, + /// Motion: gyro (pitch, yaw, roll), raw signed-16. + pub gyro: [i16; 3], + /// Motion: accelerometer (x, y, z), raw signed-16. + pub accel: [i16; 3], +} + +#[cfg(feature = "quic")] +impl PunktfunkRichInputEx { + fn to_rich(self) -> Option { + use crate::quic::RichInput; + match self.kind { + PUNKTFUNK_RICH_TOUCHPAD_EX => Some(RichInput::TouchpadEx { + pad: self.pad, + surface: self.surface, + finger: self.finger, + touch: self.active != 0, + click: self.click != 0, + x: self.x, + y: self.y, + pressure: self.pressure, + }), + PUNKTFUNK_RICH_MOTION => Some(RichInput::Motion { + pad: self.pad, + gyro: self.gyro, + accel: self.accel, + }), + PUNKTFUNK_RICH_TOUCHPAD => Some(RichInput::Touchpad { + pad: self.pad, + finger: self.finger, + active: self.active != 0, + x: self.x as u16, + y: self.y as u16, + }), + _ => None, + } + } +} + /// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8. #[cfg(feature = "quic")] unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result, ()> { @@ -1786,6 +1857,43 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input( }) } +/// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way +/// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set +/// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected. +/// +/// # Safety +/// `c` is a valid connection handle; `rich` is null or points to at least its declared +/// `struct_size` bytes. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_send_rich_input2( + c: *mut PunktfunkConnection, + rich: *const PunktfunkRichInputEx, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if rich.is_null() { + return PunktfunkStatus::NullPointer; + } + // Read only the 4-byte size prefix first to bound the subsequent full read (the + // `PunktfunkConfig` ABI-skew precedent). + let declared = unsafe { std::ptr::addr_of!((*rich).struct_size).read_unaligned() } as usize; + if declared < std::mem::size_of::() { + return PunktfunkStatus::InvalidArg; + } + match unsafe { *rich }.to_rich() { + Some(r) => match c.inner.send_rich_input(r) { + Ok(()) => PunktfunkStatus::Ok, + Err(e) => e.status(), + }, + None => PunktfunkStatus::InvalidArg, + } + }) +} + /// The currently active session mode — the Welcome's, until an accepted /// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect. /// diff --git a/design/steam-controller-deck-support.md b/design/steam-controller-deck-support.md index e75b98b..8fe4edd 100644 --- a/design/steam-controller-deck-support.md +++ b/design/steam-controller-deck-support.md @@ -1,10 +1,28 @@ # Rich Steam Controller & Steam Deck support -> **Status:** **M0–M3 GREEN + M4 desktop-capture done (2026-06-29).** The host side is complete: +> **Status:** **M0–M4 GREEN — the full Steam Controller/Deck pipeline is built (2026-06-29).** Host: > the virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD= -> steamdeck`), and the protocol carries the rich Steam inputs. The **Linux + Windows SDL clients now -> capture + send** them. Remaining M4: the Decky Disable-Steam-Input UX, Apple/Android parity, and -> the C-ABI `PunktfunkRichInputEx`/`send_rich_input2` (Apple/embedder send path). +> steamdeck`), and the protocol carries the rich inputs. Clients: the Linux + Windows SDL clients +> capture + send them; the Decky plugin has the Steam Deck mode + Disable-Steam-Input UX; the C-ABI +> has the `TouchpadEx` send path; Apple/Android round-trip the type. Remaining is **validation, not +> construction** (see below) + the deferred extras (M5 fallback-remap polish, M6 SteamOS-host +> conflict check, M7 Windows UMDF Steam driver). +> +> **M4 (complete) result:** *(desktop capture — see the prior entry.)* Plus: **C-ABI** — +> `PunktfunkRichInputEx` (size-prefixed superset) + `punktfunk_connection_send_rich_input2` (the +> `struct_size` skew-guard precedent; the only way a C client emits `TouchpadEx`); legacy +> `PunktfunkRichInput`/`send_rich_input` byte-for-byte; `punktfunk_core.h` regenerated. **Decky** — +> a "Steam Deck" gamepad option + an unmissable **Disable-Steam-Input** instruction (shown when +> selected) + a best-effort feature-detected programmatic flip in `launchStream` (never throws; the +> manual toggle is the source of truth). **Apple/Android parity** — `GamepadType.steamController/ +> steamDeck` (Swift) + `PREF_STEAMCONTROLLER/STEAMDECK` + the `0x28DE` PIDs in `prefFor` (Kotlin), so +> the type round-trips; capture stays out of scope there (iOS GameController won't surface a `28DE` +> device; Android has no rich-input plane yet). Rust workspace clippy/fmt/test green; Decky `src/` +> typechecks clean; Swift/Kotlin compile on their CI. +> +> **Pending VALIDATION (whole feature, needs hardware we don't have):** (1) recognition of the +> virtual Deck by a **running Steam** on the host; (2) a **live Deck/Steam Controller client** actually +> sending paddles/trackpads/gyro; (3) the **Moonlight paddle regression** from the M3 xpad-map change. > > **M4 (desktop client capture) result:** `clients/{linux,windows}/src/gamepad.rs` (the SDL services) > now: set the SDL HIDAPI Steam hints (`SDL_JOYSTICK_HIDAPI_STEAMDECK`/`_STEAM`) so SDL opens Valve diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index c1f446c..3d76739 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -668,6 +668,44 @@ typedef struct { } PunktfunkRichInput; #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam +// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and +// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a +// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew +// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] + +// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers. +typedef struct { + // MUST equal `sizeof(PunktfunkRichInputEx)`. + uint32_t struct_size; + // One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`). + uint8_t kind; + // Gamepad index. + uint8_t pad; + // Touchpad/TouchpadEx: contact id. + uint8_t finger; + // Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted. + uint8_t active; + // TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad. + uint8_t surface; + // TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact. + uint8_t click; + // Reserved for alignment; set to 0. + uint8_t _reserved[2]; + // TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a + // legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits. + int16_t x; + // TouchpadEx: y coordinate — signed, centred at 0. + int16_t y; + // TouchpadEx: contact pressure (`0` if the surface has no force sensor). + uint16_t pressure; + // Motion: gyro (pitch, yaw, roll), raw signed-16. + int16_t gyro[3]; + // Motion: accelerometer (x, y, z), raw signed-16. + int16_t accel[3]; +} PunktfunkRichInputEx; +#endif + // A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until // the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the // delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and @@ -1159,6 +1197,18 @@ PunktfunkStatus punktfunk_connection_send_rich_input(PunktfunkConnection *c, const PunktfunkRichInput *rich); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way +// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set +// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected. +// +// # Safety +// `c` is a valid connection handle; `rich` is null or points to at least its declared +// `struct_size` bytes. +PunktfunkStatus punktfunk_connection_send_rich_input2(PunktfunkConnection *c, + const PunktfunkRichInputEx *rich); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // The currently active session mode — the Welcome's, until an accepted // [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.