3495d189e1
Third de-coupling for the host crate carve (plan §W6.1 leaf). HostConfig + the config() OnceLock (config.rs, pure std, zero deps) move to a new pf-host-config leaf so every subsystem crate (pf-encode/pf-capture/pf-vdisplay/pf-gpu) can read process config WITHOUT depending on the orchestrator. 34 crate::config::config() call sites across 19 files repoint to pf_host_config::config(). thread_qos stays in the host for now (it calls session_tuning::on_hot_thread — its own leaf-ification rides the encode carve). Granular-crate decision (supersedes the plan's single pf-media): split capture/encode/ vdisplay into separate crates rather than one broad crate — the capture↔encode cycle is broken by a shared frame-types leaf, and vdisplay→encode (can_open_another_session) is a legal one-way edge since encode never references vdisplay. Verified: Linux (home-worker-5) clippy -p pf-host-config -p punktfunk-host --all-targets -D warnings; Windows (192.168.1.158) clippy --features nvenc,amf-qsv --all-targets green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
387 lines
18 KiB
Rust
387 lines
18 KiB
Rust
//! 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<GamepadPref>,
|
|
declared: GamepadPref,
|
|
present: bool,
|
|
) -> (GamepadPref, Option<GamepadPref>) {
|
|
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
|
|
);
|
|
}
|
|
}
|