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
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:
@@ -3,7 +3,7 @@
|
|||||||
//! the live platform) to a backend this host can actually build, applying the runtime UHID /
|
//! 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
|
//! 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
|
//! 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
|
//! cfg-split linux/other and MUST be re-verified on Windows when touched (Linux clippy can't see the
|
||||||
//! non-linux copies).
|
//! non-linux copies).
|
||||||
|
|
||||||
@@ -128,31 +128,53 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
|||||||
chosen
|
chosen
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if a **physical** Valve Steam controller (`28DE`) is already attached. The host's own Steam
|
/// The Valve product id (`28DE:xxxx`) a virtual Steam backend enumerates as, or `None` for a
|
||||||
/// Input is then managing a `28DE` device, and presenting a second (virtual) one makes Steam juggle
|
/// non-Steam backend. This is the identity the conflict gate compares against the *physical* Valve
|
||||||
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
|
/// devices attached to the host: only a genuine duplicate (same VID **and** PID) confuses Steam
|
||||||
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
|
/// Input, so the gate keys on the PID, not on the `28DE` vendor alone.
|
||||||
/// (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")]
|
#[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 {
|
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
entries.flatten().any(|e| {
|
entries.flatten().any(|e| {
|
||||||
if !e.file_name().to_string_lossy().contains(":28DE:") {
|
if !e.file_name().to_string_lossy().contains(&needle) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if std::fs::read_to_string(e.path().join("uevent"))
|
if std::fs::read_to_string(e.path().join("uevent"))
|
||||||
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF")))
|
.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()) {
|
match std::fs::read_link(e.path()) {
|
||||||
Ok(target) => {
|
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
|
/// Gate a virtual Steam pad off when a physical Steam controller *of the same identity* is attached
|
||||||
/// DualSense (then the uhid ladder), which Steam treats as an ordinary, distinct pad. Override with
|
/// (§ conflict). Degrade to DualSense (then the uhid ladder), which Steam treats as an ordinary,
|
||||||
/// `PUNKTFUNK_STEAM_FORCE=1` when the host has no competing Steam Input (e.g. a remote-only box).
|
/// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||||
if !matches!(
|
let Some(product) = steam_backend_product(chosen) else {
|
||||||
chosen,
|
return chosen; // not a virtual Steam (28DE) pad — nothing to gate
|
||||||
GamepadPref::SteamDeck
|
};
|
||||||
| GamepadPref::SteamController
|
|
||||||
| GamepadPref::SteamController2
|
|
||||||
| GamepadPref::SteamController2Puck
|
|
||||||
) {
|
|
||||||
return chosen;
|
|
||||||
}
|
|
||||||
let forced = std::env::var("PUNKTFUNK_STEAM_FORCE")
|
let forced = std::env::var("PUNKTFUNK_STEAM_FORCE")
|
||||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !forced && physical_steam_controller_present() {
|
if !forced && physical_steam_product_present(product) {
|
||||||
|
let conflict = format!("28DE:{product:04X}");
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
wanted = chosen.as_str(),
|
wanted = chosen.as_str(),
|
||||||
"a physical Steam controller is attached — the host's Steam Input would manage two 28DE \
|
conflict = conflict.as_str(),
|
||||||
devices; falling back to DualSense (set PUNKTFUNK_STEAM_FORCE=1 to override)"
|
"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);
|
return degrade_if_no_uhid(GamepadPref::DualSense);
|
||||||
}
|
}
|
||||||
@@ -383,4 +407,21 @@ mod tests {
|
|||||||
Xbox360
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user