fix(host/gamepad): only degrade a virtual Steam pad on a same-PID conflict
ci / docs-site (push) Successful in 56s
ci / web (push) Successful in 1m0s
apple / swift (push) Successful in 1m26s
decky / build-publish (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 23s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 26s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
ci / bench (push) Successful in 6m10s
apple / screenshots (push) Successful in 6m38s
deb / build-publish (push) Successful in 9m26s
android / android (push) Successful in 12m27s
deb / build-publish-host (push) Successful in 13m55s
windows-host / package (push) Successful in 16m20s
arch / build-publish (push) Successful in 17m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m2s
ci / rust (push) Successful in 27m20s
docker / deploy-docs (push) Successful in 26s

The Steam-conflict gate matched any physical Valve `28DE` HID device, so a
Steam Machine with a physical Steam Controller 2 (`28DE:1302`) plugged in
degraded a client's requested virtual Steam Deck (`28DE:1205`) to a DualSense
— the reporter's controller passthrough came up as the wrong pad.

Steam Input drives *distinct* Steam controllers side by side; only two
*identical* `28DE` devices confuse it (the Deck-as-host case: built-in
`28DE:1205` + a second virtual Deck, observed live on Bazzite). Narrow the
gate to key on the exact VID+PID instead of the `28DE` vendor alone:

- `steam_backend_product()` maps each virtual Steam backend to the PID it
  enumerates as (Deck 1205, classic SC 1102, SC2 1302, SC2 Puck 1304).
- `physical_steam_controller_present()` -> `physical_steam_product_present(pid)`
  matches `:28DE:{PID}`, keeping the FVPF-serial / `vhci_hcd` / `/virtual/`
  exclusions so our own virtual pads never count.
- `degrade_steam_on_conflict` degrades only on a same-PID duplicate and logs
  the conflicting identity. `PUNKTFUNK_STEAM_FORCE=1` override unchanged.

A physical SC2 no longer blocks a virtual Deck (and vice versa); a genuine
same-identity duplicate (physical Deck + virtual Deck, or physical SC2 +
SC2 passthrough) still degrades. Adds a unit test for the PID mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:11:44 +02:00
co-authored by Claude Opus 4.8
parent 4714235fe6
commit 588a7077e8
+72 -31
View File
@@ -3,7 +3,7 @@
//! 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
//! (`degrade_if_no_uhid`, `physical_steam_product_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).
@@ -128,31 +128,53 @@ 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.
/// The Valve product id (`28DE:xxxx`) a virtual Steam backend enumerates as, or `None` for a
/// non-Steam backend. This is the identity the conflict gate compares against the *physical* Valve
/// devices attached to the host: only a genuine duplicate (same VID **and** PID) confuses Steam
/// Input, so the gate keys on the PID, not on the `28DE` vendor alone.
#[cfg(target_os = "linux")]
fn physical_steam_controller_present() -> bool {
fn steam_backend_product(pref: GamepadPref) -> Option<u16> {
match pref {
GamepadPref::SteamDeck => Some(0x1205), // built-in Deck controller identity
GamepadPref::SteamController => Some(0x1102), // classic wired Steam Controller
GamepadPref::SteamController2 => Some(0x1302), // Steam Controller 2 (Triton), wired
GamepadPref::SteamController2Puck => Some(0x1304), // Steam Controller 2 via its Puck dongle
_ => None,
}
}
/// True if a **physical** Valve device with this exact product id (`28DE:{product}`) is already
/// attached. The host's own Steam Input is then managing that identity, and presenting a *second,
/// identical* virtual one makes Steam juggle two copies of the same Deck — confirmed conflict-prone
/// on a Deck-as-host (the physical `28DE:1205` + Steam's `28DE:11FF` XInput output pad are both
/// live).
///
/// Crucially this keys on the PID, not just the `28DE` vendor: a *different* Valve product is not a
/// conflict. A physical Steam Controller 2 (`28DE:1302`) must NOT block a virtual Steam Deck
/// (`28DE:1205`) — Steam Input drives distinct Steam controllers side by side, so the wanted pad
/// has to pass through unharmed (this over-broad vendor match degraded such sessions to DualSense).
///
/// 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 pads 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_product_present(product: u16) -> bool {
let needle = format!(":28DE:{product:04X}");
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:") {
if !e.file_name().to_string_lossy().contains(&needle) {
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
return false; // one of our own virtual pads
}
match std::fs::read_link(e.path()) {
Ok(target) => {
@@ -164,28 +186,30 @@ fn physical_steam_controller_present() -> bool {
})
}
/// 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).
/// Gate a virtual Steam pad off when a physical Steam controller *of the same identity* 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).
///
/// Only a same-PID duplicate degrades: a physical Steam Controller 2 no longer blocks a virtual
/// Steam Deck (and vice versa), so a Steam Machine with an SC2 plugged in streams the pad the client
/// actually asked for.
#[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 Some(product) = steam_backend_product(chosen) else {
return chosen; // not a virtual Steam (28DE) pad — nothing to gate
};
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() {
if !forced && physical_steam_product_present(product) {
let conflict = format!("28DE:{product:04X}");
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)"
conflict = conflict.as_str(),
"a physical Steam controller of the same identity is attached — the host's Steam Input \
would manage two identical 28DE devices; falling back to DualSense (set \
PUNKTFUNK_STEAM_FORCE=1 to override)"
);
return degrade_if_no_uhid(GamepadPref::DualSense);
}
@@ -383,4 +407,21 @@ mod tests {
Xbox360
);
}
// The conflict gate keys on the exact Valve PID, so a physical Steam Controller 2 (`28DE:1302`)
// never blocks a virtual Steam Deck (`28DE:1205`) — only a same-identity duplicate degrades.
#[cfg(target_os = "linux")]
#[test]
fn steam_backend_product_ids() {
use super::steam_backend_product;
use GamepadPref::*;
assert_eq!(steam_backend_product(SteamDeck), Some(0x1205));
assert_eq!(steam_backend_product(SteamController), Some(0x1102));
assert_eq!(steam_backend_product(SteamController2), Some(0x1302));
assert_eq!(steam_backend_product(SteamController2Puck), Some(0x1304));
// Non-Steam backends carry no Valve identity, so the gate never fires for them.
assert_eq!(steam_backend_product(DualSense), None);
assert_eq!(steam_backend_product(Xbox360), None);
assert_eq!(steam_backend_product(SwitchPro), None);
}
}