fix(gamestream/windows): Moonlight sessions built the XUSB pad that Steam cannot see

Reported from the field: gamepad input does nothing on GameStream clients, and it
reproduces across every client and device a user tries (Artemis on a Steam Deck and
on an Android phone both). That breadth is the tell — it is not a client at all.

On Windows there are two virtual Xbox backends and they are not interchangeable to a
game. The XUSB companion registers only GUID_DEVINTERFACE_XUSB and exposes no HID
collection (pf_xusb.inx says so in its own header: "a non-HID UMDF2 driver", Class =
System), so Steam's hidapi enumeration, SDL, RawInput, DirectInput, joy.cpl and
WGI/GameInput cannot see it at all — only classic XInputGetState can. The native plane
moved to the real HID pad as its DEFAULT on 2026-08-09 (bd5735b8) for exactly that
reason; its doc comment records the reporter who lost a controller for two weeks to it.

The GameStream plane never got that change. It has bound `crate::inject::gamepad`
since the first gamepad commit, when that name meant uinput and Windows had no second
backend; Windows later gave the same name the XUSB companion, so this plane inherited
it by module-name coincidence rather than by any decision. bd5735b8 did touch
control.rs — but only to widen the rumble closure's arity, and its note reasons about
"the uinput backend", not about the Windows one sitting behind the same import. So
every Moonlight session since has presented a pad most games cannot enumerate, while
native punktfunk sessions on the same host got the good one.

There was also no way out: `windows_xbox_hid` was `pub(super)`, i.e. unreachable from
this module, so PUNKTFUNK_XBOX_BACKEND did nothing here — and the Windows manager
ignores the arrival `kind` byte, so a client could not ask for a DualSense either, the
escape that saved the earlier reporter.

- native.rs / native/gamepad.rs: `mod gamepad` and `windows_xbox_hid` become
  pub(crate). The knob keeps ONE definition and one name; widening visibility breaks
  no existing caller. Its doc now records that both planes read it, and why being
  `pub(super)` was itself the bug.
- gamestream/control.rs: a `SessionPads` enum is the one place this plane picks a
  backend — the HID pad when the shared knob says so, the XUSB companion otherwise,
  and on every other platform the single backend that exists.
  `PUNKTFUNK_XBOX_BACKEND=xusb` now reverts both planes together.

The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no
lightbar or adaptive triggers, and GameStream has no vocabulary for one — its rumble
message (0x010B) carries the two handle motors and nothing else, which is why the
trigger levels were already dropped at the call site.

No test: a `cfg(target_os = "windows")` #[test] would compile NOWHERE. ci.yml excludes
it by target, and windows-host.yml lints the host with `cargo clippy -p punktfunk-host`
without `--all-targets`, which does not build `cfg(test)` modules at all — the same
blind spot that workflow's own comments blame for letting the Linux twin's tests rot to
the wrong arity. It would be dead weight, not coverage.

VERIFIED
  * ON WINDOWS (.133, the only box where this arm compiles at all):
    `cargo check -p punktfunk-host` clean in 57s, then
    `cargo clippy -p punktfunk-host -- -D warnings` clean in 42s — both on default
    features, which include `gamestream`. This compiles BOTH enum arms: `Xusb` and
    `Hid` are Windows types alike.
    Non-vacuous by construction: `cargo clean -p punktfunk-host` ran first (a scp'd
    tree plus this box's lagging clock and a warm shared target dir otherwise yield a
    fresh `Finished` having compiled nothing), and both passes logged
    `Compiling punktfunk-host v0.31.0`. The only warning is punktfunk-core's
    header-write notice — benign and pre-existing.
  * `cargo fmt --all --check` clean.

NOT VERIFIED — owed
  * Linux: no box could run it (.25's root filesystem is 100% full; .21/.136/.138 were
    down; OrbStack here hangs even on `docker run alpine echo`). Exposure is small —
    the Linux arm is three calls on a `GamepadManager` whose Windows XUSB sibling has
    the identical method surface and just compiled — and ci.yml DOES run on
    pull_request, so opening a PR closes this.
  * On glass: no Moonlight session has driven the HID pad through this plane yet. That
    is the real acceptance test, and the log line to look for is
    "virtual Xbox pad created (Windows UMDF HID)" where it used to say
    "virtual Xbox 360 created (Windows XUSB companion)".
  * windows-host.yml has NO `pull_request` trigger (push to main, v* tags,
    workflow_dispatch only), so CI will not re-check this arm on a PR — the .133 run
    above is deliberately standing in for it.
This commit is contained in:
2026-08-20 18:19:19 +02:00
parent f2b5b3e567
commit dfcffcdd50
3 changed files with 74 additions and 8 deletions
@@ -30,7 +30,7 @@
use super::{AppState, CONTROL_PORT};
use crate::inject::gamepad::GamepadManager;
use anyhow::{anyhow, Context, Result};
use punktfunk_core::input::InputEvent;
use punktfunk_core::input::{GamepadEvent, InputEvent};
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
use std::net::UdpSocket;
@@ -229,6 +229,65 @@ fn permitted(mask: u32, class: GrantClass, drops: &mut GrantDrops) -> bool {
false
}
/// The virtual Xbox pad a Moonlight session presents, and the one place this plane decides which
/// backend builds it.
///
/// On Windows there are two, and they are not interchangeable to a game: the XUSB companion
/// registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi
/// enumeration, SDL, RawInput, DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all —
/// only classic `XInputGetState` can. The native plane made the HID pad its default on
/// 2026-08-09 for exactly that reason; this plane kept constructing
/// [`GamepadManager`](crate::inject::gamepad::GamepadManager) directly and so kept handing
/// Moonlight clients a pad most games cannot enumerate. Both planes now read the same knob —
/// `native::gamepad::windows_xbox_hid` (not an intra-doc link: it is `cfg(windows)`, so the link
/// would not resolve on any other target) — so `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both
/// together and neither can drift again.
///
/// Everywhere else the choice does not exist: Linux has one uinput X-Box pad, and the stub
/// backend on other platforms drops events.
enum SessionPads {
/// Linux uinput / the Windows XUSB companion — `crate::inject::gamepad`.
Xusb(GamepadManager),
/// The Windows UMDF HID Xbox pad, what the native plane builds by default.
#[cfg(target_os = "windows")]
Hid(crate::inject::xbox_windows::XboxWindowsManager),
}
impl SessionPads {
/// Build this session's pad manager, honoring the shared Windows backend knob.
fn new() -> SessionPads {
#[cfg(target_os = "windows")]
if crate::native::gamepad::windows_xbox_hid() {
return SessionPads::Hid(crate::inject::xbox_windows::XboxWindowsManager::new());
}
SessionPads::Xusb(GamepadManager::new())
}
/// Apply one decoded controller event (create/destroy by mask, then state).
fn handle(&mut self, ev: &GamepadEvent) {
match self {
SessionPads::Xusb(m) => m.handle(ev),
#[cfg(target_os = "windows")]
SessionPads::Hid(m) => m.handle(ev),
}
}
/// Service the pads' feedback protocol and relay changed rumble levels. Games block inside the
/// kernel/driver handshake until answered, so call this every tick.
///
/// The HID pad's rich-feedback plane is discarded rather than plumbed: an Xbox pad has no
/// lightbar or adaptive triggers to report, and GameStream has no vocabulary for one either —
/// its rumble message (`0x010B`, [`super::gamepad::rumble_plaintext`]) carries the two handle
/// motors and nothing else, which is also why the trigger levels are dropped at the call site.
fn pump_rumble(&mut self, rumble: impl FnMut(u16, u16, u16, u16, u16)) {
match self {
SessionPads::Xusb(m) => m.pump_rumble(rumble),
#[cfg(target_os = "windows")]
SessionPads::Hid(m) => m.pump(rumble, |_| {}),
}
}
}
/// Reconcile the control port to the paired-client list: bound while at least one pairing
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
/// wherever the paired list changes — startup, pairing phase 4, unpair.
@@ -362,7 +421,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
// by every outbound message (rumble + the HDR-mode signal): the GCM nonce is derived
// from `seq`, so a per-message-type counter would reuse (key, nonce) pairs across
// message types in the host direction.
let mut pads = GamepadManager::new();
let mut pads = SessionPads::new();
// Pen/touch translator (SS_PEN/SS_TOUCH → virtual tablet / wire touch). Sent only
// by clients that saw our SS_FF_PEN_TOUCH_EVENTS feature flag (rtsp.rs).
let mut pointer = super::pen::GsPointer::new();
@@ -480,7 +539,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
hdr_sent = false;
// Unplug the session's virtual pads + tablet (destroying the
// uinput pen releases any held tool/tip kernel-side).
pads = GamepadManager::new();
pads = SessionPads::new();
pointer = super::pen::GsPointer::new();
// Surface the session's enforcement-drop totals (WP13).
drops.end_of_session();
@@ -583,7 +642,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
detected = None;
decrypt_fails = 0;
hdr_sent = false;
pads = GamepadManager::new();
pads = SessionPads::new();
pointer = super::pen::GsPointer::new();
drops.end_of_session();
}
@@ -689,7 +748,7 @@ fn on_receive(
detected: &mut Option<Scheme>,
decrypt_fails: &mut u64,
inj_tx: &Sender<InputEvent>,
pads: &mut GamepadManager,
pads: &mut SessionPads,
pointer: &mut super::pen::GsPointer,
grants: u32,
drops: &mut GrantDrops,
+4 -2
View File
@@ -48,8 +48,10 @@ mod compositor;
use compositor::resolve_compositor;
/// Virtual-gamepad backend resolution (plan §W1); `serve_session` + the `Pads` state machine reach
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here.
mod gamepad;
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here. Crate-visible because the choice of
/// Windows Xbox backend (`windows_xbox_hid`) is not the native plane's alone — the GameStream plane
/// presents the same virtual pad and has to make the same choice, from one definition.
pub(crate) mod gamepad;
use gamepad::{resolve_gamepad, resolve_pad_kind, route_decision};
/// The SPAKE2 pairing ceremony (plan §W1); `serve_session` dispatches a PairRequest connection here.
+6 -1
View File
@@ -363,8 +363,13 @@ fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref {
///
/// The two backends are mutually exclusive per pad by construction (one match arm or the other) —
/// presenting both would hand a game two controllers for one pair of hands.
///
/// Read by BOTH input planes. The native plane branches on it in `Pads::handle`; the GameStream
/// plane in `gamestream::control::SessionPads`. It was `pub(super)` while only the native plane
/// consulted it, and that is exactly how Moonlight sessions spent two releases on the XUSB pad
/// after this default flipped — the knob was unreachable from the module that needed it.
#[cfg(target_os = "windows")]
pub(super) fn windows_xbox_hid() -> bool {
pub(crate) fn windows_xbox_hid() -> bool {
match std::env::var("PUNKTFUNK_XBOX_BACKEND") {
Ok(v) if v.trim().eq_ignore_ascii_case("xusb") => false,
// Anything else — unset, empty, "hid", or a typo — takes the default. A misspelled opt-out