From 4f40fa3cb77b53d7a629f75877ffc1ef05db8879 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 29 Jun 2026 13:15:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(host/steam):=20M6=20=E2=80=94=20Steam-pad?= =?UTF-8?q?=20conflict=20gate=20(hardware-validated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't present a virtual Steam (28DE) pad on a host that already has a physical Steam controller — the host's own Steam Input would then manage two Decks and confuse player assignment. - physical_steam_controller_present(): scans /sys/bus/hid/devices for a 28DE HID device on a real (non-/virtual/) path. - degrade_steam_on_conflict() in resolve_gamepad: a resolved SteamDeck / SteamController with a physical Steam controller attached degrades to DualSense (then the M5 uhid ladder); PUNKTFUNK_STEAM_FORCE=1 overrides (e.g. a remote-only box with no competing Steam Input). Validated on real hardware (a SteamOS Steam Deck @ .253 + a Bazzite host @ .41, both running Steam): - Conflict confirmed: the Deck-as-host already has its physical 28DE:1205 AND Steam's 28DE:11FF XInput output pad live; a 2nd virtual 28DE = two Decks. - Bind robustness: the virtual Deck binds hid-steam on a SECOND kernel (Bazzite 6.17.7, vs the dev box 7.0) and the kernel accepted our serial (the M1 fix). - Criterion-4 (running-Steam recognition) PARTIAL: a userspace consumer (Steam/ SDL) engaged the virtual Deck (opened the hidraw, ran the lizard-disable + settings sequence the kernel's Deck path skips) but emitted NO 28DE:11FF XInput pad on the desktop — so Steam recognizes it enough to manage lizard mode but did not promote it to a managed XInput controller (likely needs a Big-Picture/game context, or a richer device; the 0x83/0xA1 attribute probes never fired, so it wasn't a probe-reject either). - The heuristic itself checks TRUE on the Deck, FALSE on Bazzite. Workspace clippy/fmt/test green. Not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/punktfunk1.rs | 55 +++++++++++++++++++++++++ design/steam-controller-deck-support.md | 43 ++++++++++++++----- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 5d96803..4cdf6bc 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1947,6 +1947,57 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { chosen } +/// True if a **physical** Valve Steam controller (`28DE`) is already attached. The host's own Steam +/// Input is then managing a `28DE` device, and presenting a second (virtual) one makes Steam juggle +/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's +/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST` +/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not. +#[cfg(target_os = "linux")] +fn physical_steam_controller_present() -> bool { + let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else { + return false; + }; + entries.flatten().any(|e| { + if !e.file_name().to_string_lossy().contains(":28DE:") { + return false; + } + match std::fs::read_link(e.path()) { + Ok(target) => !target.to_string_lossy().contains("/virtual/"), + Err(_) => true, + } + }) +} + +/// Gate a virtual Steam pad off when a physical Steam controller is attached (§ conflict). Degrade to +/// DualSense (then the uhid ladder), which Steam treats as an ordinary, distinct pad. Override with +/// `PUNKTFUNK_STEAM_FORCE=1` when the host has no competing Steam Input (e.g. a remote-only box). +#[cfg(target_os = "linux")] +fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { + if !matches!( + chosen, + GamepadPref::SteamDeck | GamepadPref::SteamController + ) { + return chosen; + } + let forced = std::env::var("PUNKTFUNK_STEAM_FORCE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if !forced && physical_steam_controller_present() { + tracing::warn!( + wanted = chosen.as_str(), + "a physical Steam controller is attached — the host's Steam Input would manage two 28DE \ + devices; falling back to DualSense (set PUNKTFUNK_STEAM_FORCE=1 to override)" + ); + return degrade_if_no_uhid(GamepadPref::DualSense); + } + chosen +} + +#[cfg(not(target_os = "linux"))] +fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { + chosen +} + /// 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 { @@ -1961,6 +2012,10 @@ fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { // backends need `/dev/uhid` usable *now*, else creating the device just fails and the controller // goes dead — fall back to the always-available uinput X-Box 360 pad instead. let chosen = degrade_if_no_uhid(chosen); + // Conflict gate: don't present a virtual Steam (28DE) pad when the host already has a physical + // Steam controller — its own Steam Input would then manage two Decks (confirmed conflict-prone on + // a Deck-as-host). `PUNKTFUNK_STEAM_FORCE=1` overrides. + let chosen = degrade_steam_on_conflict(chosen); match pref { GamepadPref::Auto => { // The operator's env knob deserves a diagnostic when it didn't drive the diff --git a/design/steam-controller-deck-support.md b/design/steam-controller-deck-support.md index c0d9918..e10622f 100644 --- a/design/steam-controller-deck-support.md +++ b/design/steam-controller-deck-support.md @@ -1,12 +1,31 @@ # Rich Steam Controller & Steam Deck support -> **Status:** **M0–M5 GREEN — full pipeline + fallback polish built (2026-06-29).** Host: the -> virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD=steamdeck`), -> the protocol carries the rich inputs, and the **fallback remap** keeps them from silently dropping -> on a non-Steam backend. 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 (M6 SteamOS-host conflict check, M7 Windows UMDF Steam driver). +> **Status:** **M0–M6 GREEN — full pipeline + fallback + conflict gate built (2026-06-29).** Host: +> the virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD=steamdeck`), +> the protocol carries the rich inputs, the **fallback remap** keeps them from silently dropping, and +> the **conflict gate** keeps a virtual Steam pad off a host that already has a physical one. 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: M7 (Windows UMDF Steam driver) + the last hardware validation. +> +> **M6 (conflict gate) result — validated on real hardware (a SteamOS Deck + a Bazzite host running +> Steam, 2026-06-29):** (a) **Empirical conflict confirmed.** A Deck-as-host already has its physical +> `28DE:1205` *and* Steam's `28DE:11FF` XInput output pad live — so a second virtual `28DE` makes Steam +> juggle two Decks. (b) **Bind robustness:** the virtual Deck binds `hid-steam` on a *second* +> independent kernel (Bazzite 6.17.7, vs the dev box 7.0) and the kernel accepted our serial (the M1 +> report-id-0 fix). (c) **Criterion-4 (running-Steam recognition) — partial:** with Steam running on +> Bazzite, a userspace consumer (Steam/SDL) *did* engage the virtual Deck — it opened the hidraw and +> ran the lizard-disable + settings sequence (`0xAE`/`0x85`/`0x8E`/`0x81`/`0x87`; the kernel's Deck path +> skips lizard, so that's Steam/SDL) — **but emitted no `28DE:11FF` XInput pad** on the desktop, even +> though the Deck proves desktop-Steam *does* emit one for a recognized Deck. So Steam recognizes the +> device enough to manage its lizard mode but did **not** promote it to a managed XInput controller — +> likely needs a Big-Picture/game foreground context or a richer device (the `0x83`/`0xA1` attribute +> probes the research feared never fired, so it wasn't a probe-reject either). **Policy (code):** +> `physical_steam_controller_present()` (scans `/sys/bus/hid/devices` for a non-virtual `28DE`) + +> `degrade_steam_on_conflict()` in `resolve_gamepad` — a resolved `SteamDeck`/`SteamController` on a +> host with a physical Steam controller degrades to DualSense (then the uhid ladder), overridable with +> `PUNKTFUNK_STEAM_FORCE=1`. Heuristic hardware-checked: **TRUE on the Deck, FALSE on Bazzite.** +> Workspace clippy/fmt/test green. > > **M5 (fallback remap + degrade ladder) result:** new pure, unit-tested `inject/proto/steam_remap.rs`: > (1) **motion rescale** `motion_wire_to_deck` — the wire carries DualSense-convention units (what @@ -34,9 +53,13 @@ > 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. +> **Pending VALIDATION:** (1) **running-Steam recognition — PARTIAL** (M6: Steam engages the virtual +> Deck's hidraw but didn't emit an XInput pad on the desktop; re-test with a foreground Big-Picture +> game on the Deck/Bazzite to see if it promotes then — and if not, capture the real `0x83`/`0xA1` +> attribute blobs from a physical Deck for M7-style fidelity); (2) a **live Deck/Steam Controller +> client** actually sending paddles/trackpads/gyro (the desktop capture is built but unexercised by +> real hardware — note the Deck's `/dev/uhid` is root-only `0600`, so the Deck-as-host needs a udev +> rule for the input group); (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