//! Virtual-gamepad backend resolution for the native session (plan §W1 — carved out of the //! [`super`] module). Maps the client's [`GamepadPref`] (plus the host `PUNKTFUNK_GAMEPAD` env and //! the live platform) to a backend this host can actually build, applying the runtime UHID / //! Steam-conflict degrades. Pure selection ([`pick_gamepad`]) is separated from the env/logging //! shell ([`resolve_gamepad`]) and the per-pad variant ([`resolve_pad_kind`]); the platform degrades //! (`degrade_if_no_uhid`, `physical_steam_controller_present`, `degrade_steam_on_conflict`) are //! cfg-split linux/other and MUST be re-verified on Windows when touched (Linux clippy can't see the //! non-linux copies). use super::*; /// The per-pad routing decision for one frame ([`Pads::handle`]): given `owner` (the manager /// holding a live device at this index, if any), the client-`declared` kind, and whether this is a /// create/update frame (`present`) vs a removal, return `(kind to route to, new owner)`. /// /// A live device stays in its owning manager even if the declared kind later changes (so a pad is /// never duplicated across managers); the declared kind takes effect only when no device exists /// yet; a removal routes to the owner's manager (so it tears the right device down) and clears the /// owner. pub(super) fn route_decision( owner: Option, declared: GamepadPref, present: bool, ) -> (GamepadPref, Option) { match (owner, present) { (Some(k), true) => (k, Some(k)), // keep the existing device in its manager (Some(k), false) => (k, None), // removal → owner's manager, then clear (None, true) => (declared, Some(declared)), // create in the declared kind's manager (None, false) => (declared, None), // removal with no device — a harmless no-op } } /// Resolve one client-declared per-pad kind to a backend this host can actually build (mixed /// types): the platform map + the runtime UHID / Steam-conflict degrades that [`resolve_gamepad`] /// applies to the session default, minus the Auto/env session logic (a per-pad declaration is /// always a concrete kind). pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref { let chosen = pick_gamepad( kind, None, cfg!(target_os = "linux"), cfg!(target_os = "windows"), ); degrade_steam_on_conflict(degrade_if_no_uhid(chosen)) } /// 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. /// /// `linux`/`windows` flag the host platform. DualSense and DualShock 4 each have both a Linux (UHID /// hid-playstation) and a Windows (UMDF minidriver) backend; on any other platform such a wish degrades /// to X-Box 360 (never an error: a session without rich pads still streams). X-Box One/Series is a /// distinct uinput *identity* on Linux, but XInput-identical to the 360 pad on Windows (the XUSB /// companion presents a 360 identity), so it degrades to `Xbox360` there. fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref { let want = match pref { GamepadPref::Auto => env .and_then(GamepadPref::from_name) .unwrap_or(GamepadPref::Auto), explicit => explicit, }; match want { // DualSense / DualShock 4: Linux UHID hid-playstation, or the Windows UMDF minidriver backend. GamepadPref::DualSense if linux || windows => GamepadPref::DualSense, GamepadPref::DualShock4 if linux || windows => GamepadPref::DualShock4, // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on // Windows (XInput can't tell them apart anyway). GamepadPref::XboxOne if linux => GamepadPref::XboxOne, // Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices // are the N4 spike). GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, GamepadPref::SteamController if linux => GamepadPref::SteamController, // Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the // MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads + // gyro + back grips, replacing the old fold to DualSense. GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck, // DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain // DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop // policy. Degrades to Xbox360 elsewhere like its siblings. GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge, // Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional // layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there. GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro, // New Steam Controller (2026, `28DE:1302`): passed through as-is on Linux — the Triton // UHID backend mirrors the client's raw reports under the real identity and Steam on // the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the // consumer). No Windows backend; folds to Xbox360 there. GamepadPref::SteamController2 if linux => GamepadPref::SteamController2, GamepadPref::SteamController2Puck if linux => GamepadPref::SteamController2Puck, _ => GamepadPref::Xbox360, } } /// Runtime degrade for the Linux UHID backends (DualSense / DualShock 4 / Steam Deck): if /// `/dev/uhid` can't be opened for write *now*, fall back to the uinput X-Box 360 pad rather than a /// dead controller (the UHID device-create would just fail). Cheap — opens + drops the char device, /// no `UHID_CREATE2`, so no device is created. A no-op on non-Linux (those backends are UMDF/uinput). #[cfg(target_os = "linux")] fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { let needs_uhid = matches!( chosen, GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4 | GamepadPref::SteamDeck | GamepadPref::SteamController | GamepadPref::SteamController2 | GamepadPref::SteamController2Puck | GamepadPref::SwitchPro ); if needs_uhid && std::fs::OpenOptions::new() .write(true) .open("/dev/uhid") .is_err() { tracing::warn!( wanted = chosen.as_str(), "/dev/uhid not writable — falling back to the X-Box 360 pad" ); return GamepadPref::Xbox360; } chosen } #[cfg(not(target_os = "linux"))] 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. /// /// Punktfunk's OWN virtual Decks must never count: the usbip/gadget transports present a real USB /// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's /// pad still detaching — or a concurrent session's live one — read as "physical" and degraded /// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are /// recognizable by the `FVPF…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the /// vhci path as belt and braces. #[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; } if std::fs::read_to_string(e.path().join("uevent")) .is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF"))) { return false; // one of our own virtual Decks } match std::fs::read_link(e.path()) { Ok(target) => { let t = target.to_string_lossy(); !t.contains("/virtual/") && !t.contains("vhci_hcd") } 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 | GamepadPref::SteamController2 | GamepadPref::SteamController2Puck ) { 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. pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { let env = pf_host_config::config().gamepad.clone(); let chosen = pick_gamepad( pref, env.as_deref(), cfg!(target_os = "linux"), cfg!(target_os = "windows"), ); // Runtime degrade (separate from the compile-time platform check above): the Linux UHID // 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 // 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 } #[cfg(test)] mod tests { use super::{pick_gamepad, route_decision}; use punktfunk_core::config::GamepadPref; #[test] fn per_pad_route_decision() { use GamepadPref::{DualSense, Xbox360}; // First frame with no device: create in the declared kind's manager, record ownership. assert_eq!( route_decision(None, DualSense, true), (DualSense, Some(DualSense)) ); // Subsequent frame: stays in the owning manager even if the declared kind now differs // (the arrival-after-first-frame reorder) — never a second device in another manager. assert_eq!( route_decision(Some(DualSense), Xbox360, true), (DualSense, Some(DualSense)) ); // Removal (cleared bit): routes to the owner so the RIGHT device is torn down, then clears. assert_eq!( route_decision(Some(DualSense), Xbox360, false), (DualSense, None) ); // Removal with no device is a harmless no-op route (owner stays cleared). assert_eq!(route_decision(None, Xbox360, false), (Xbox360, None)); // A fresh device after a re-plug picks up the newly-declared kind (owner was cleared). assert_eq!( route_decision(None, Xbox360, true), (Xbox360, Some(Xbox360)) ); } #[test] fn gamepad_resolution_precedence() { use GamepadPref::*; // Trailing args are (linux, windows). // An explicit client choice wins over the env var. assert_eq!( pick_gamepad(DualSense, Some("xbox360"), true, false), DualSense ); assert_eq!( pick_gamepad(Xbox360, Some("dualsense"), true, false), Xbox360 ); // Client Auto defers to the env var. assert_eq!( pick_gamepad(Auto, Some("dualsense"), true, false), DualSense ); assert_eq!(pick_gamepad(Auto, Some("xbox360"), true, false), Xbox360); // Auto + no env (or an unparseable one) → X-Box 360. assert_eq!(pick_gamepad(Auto, None, true, false), Xbox360); assert_eq!(pick_gamepad(Auto, Some("bogus"), true, false), Xbox360); // DualSense: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere. assert_eq!(pick_gamepad(DualSense, None, false, true), DualSense); assert_eq!( pick_gamepad(Auto, Some("dualsense"), false, true), DualSense ); assert_eq!(pick_gamepad(DualSense, None, false, false), Xbox360); assert_eq!(pick_gamepad(Auto, Some("dualsense"), false, false), Xbox360); // DualShock 4: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere. assert_eq!(pick_gamepad(DualShock4, None, true, false), DualShock4); assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4); assert_eq!(pick_gamepad(DualShock4, None, false, true), DualShock4); assert_eq!(pick_gamepad(DualShock4, None, false, false), Xbox360); // X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows. assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne); assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne); assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360); // Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3, // Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere. assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck); assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck); assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck); assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360); // Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere. assert_eq!( pick_gamepad(SteamController, None, true, false), SteamController ); assert_eq!( pick_gamepad(Auto, Some("steamcontroller"), true, false), SteamController ); assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360); // DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360 // elsewhere. assert_eq!( pick_gamepad(DualSenseEdge, None, true, false), DualSenseEdge ); assert_eq!( pick_gamepad(DualSenseEdge, None, false, true), DualSenseEdge ); assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge); assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360); // Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere. assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro); assert_eq!( pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro ); assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro); assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360); assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360); // New Steam Controller (as-is Triton passthrough): native on Linux (UHID, Steam-driven); // Xbox360 on Windows and elsewhere. assert_eq!( pick_gamepad(SteamController2, None, true, false), SteamController2 ); assert_eq!( pick_gamepad(Auto, Some("sc2"), true, false), SteamController2 ); assert_eq!( pick_gamepad(Auto, Some("ibex"), true, false), SteamController2 ); assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360); assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360); assert_eq!( pick_gamepad(SteamController2Puck, None, true, false), SteamController2Puck ); assert_eq!( pick_gamepad(Auto, Some("sc2puck"), true, false), SteamController2Puck ); assert_eq!( pick_gamepad(SteamController2Puck, None, false, true), Xbox360 ); } }